refactor: move affinity config to quota section with unified style

- AffinityConfigCard redesigned to match QuotaLimitCard style (section title + toggle card)
- Moved from standalone section to quota area, visible for all Anthropic account types
- Green zone (base) allows empty = no limit, min=1 when set; yellow zone hidden when base unset
- Deduplicated affinity save logic in EditAccountModal (single block for all Anthropic types)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
erio
2026-03-07 17:20:43 +08:00
co-authored by Claude Opus 4.6
parent 56c2799293
commit 165cd33621
4 changed files with 102 additions and 136 deletions
@@ -35,6 +35,8 @@ const onBaseInput = (e: Event) => {
emit('update:base', Number.isNaN(raw) ? null : Math.max(1, Math.floor(raw)))
}
const hasBaseLimit = computed(() => props.base != null && props.base > 0)
// buffer: empty = null (infinite yellow), 0 = no yellow, >0 = yellow range
const bufferIsInfinite = ref(props.buffer === null || props.buffer === undefined)
@@ -78,85 +80,94 @@ const zonePreview = computed(() => {
</script>
<template>
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-600">
<div class="mb-3 flex items-center justify-between">
<div>
<label class="input-label mb-0">{{ t('admin.accounts.affinityToggle') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.affinityToggleHint') }}
</p>
</div>
<button
type="button"
@click="localEnabled = !localEnabled"
:class="[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
localEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
localEnabled ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
<div class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4">
<div class="mb-3">
<h3 class="input-label mb-0 text-base font-semibold">{{ t('admin.accounts.affinitySection') }}</h3>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.affinitySectionHint') }}
</p>
</div>
<div v-if="localEnabled" class="space-y-3">
<!-- Base limit (green zone) -->
<div>
<label class="input-label">{{ t('admin.accounts.affinityBase') }}</label>
<input
:value="base"
@input="onBaseInput"
type="number"
min="1"
step="1"
class="input"
:placeholder="t('admin.accounts.affinityBasePlaceholder')"
/>
<p class="input-hint">{{ t('admin.accounts.affinityBaseHint') }}</p>
</div>
<!-- Buffer (yellow zone) -->
<div>
<div class="flex items-center justify-between mb-1">
<label class="input-label mb-0">{{ t('admin.accounts.affinityBuffer') }}</label>
<label class="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400 cursor-pointer">
<input
type="checkbox"
:checked="bufferIsInfinite"
@change="toggleBufferInfinite"
class="h-3.5 w-3.5 rounded border-gray-300 text-primary-600"
/>
{{ t('admin.accounts.affinityBufferInfinite') }}
</label>
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-600">
<div class="mb-3 flex items-center justify-between">
<div>
<label class="input-label mb-0">{{ t('admin.accounts.affinityToggle') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.affinityToggleHint') }}
</p>
</div>
<input
v-if="!bufferIsInfinite"
:value="buffer"
@input="onBufferInput"
type="number"
min="0"
step="1"
class="input"
:placeholder="t('admin.accounts.affinityBufferPlaceholder')"
/>
<p class="input-hint">{{ t('admin.accounts.affinityBufferHint') }}</p>
<button
type="button"
@click="localEnabled = !localEnabled"
:class="[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
localEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
localEnabled ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
</div>
<!-- Zone preview -->
<div v-if="zonePreview && base && base > 0" class="flex items-center gap-2 text-xs">
<span class="inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400">
{{ zonePreview.green }}
</span>
<span v-if="zonePreview.yellow" class="inline-flex items-center gap-1 rounded-full bg-yellow-100 px-2 py-0.5 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400">
{{ zonePreview.yellow }}
</span>
<span v-if="zonePreview.red" class="inline-flex items-center gap-1 rounded-full bg-red-100 px-2 py-0.5 text-red-700 dark:bg-red-900/30 dark:text-red-400">
{{ zonePreview.red }}
</span>
<div v-if="localEnabled" class="space-y-3">
<!-- Base limit (green zone) -->
<div>
<label class="input-label">{{ t('admin.accounts.affinityBase') }}</label>
<input
:value="base"
@input="onBaseInput"
type="number"
min="1"
step="1"
class="input"
:placeholder="t('admin.accounts.affinityBasePlaceholder')"
/>
<p class="input-hint">{{ t('admin.accounts.affinityBaseHint') }}</p>
</div>
<!-- Buffer (yellow zone) - only shown when base is set -->
<div v-if="hasBaseLimit">
<div class="flex items-center justify-between mb-1">
<label class="input-label mb-0">{{ t('admin.accounts.affinityBuffer') }}</label>
<label class="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400 cursor-pointer">
<input
type="checkbox"
:checked="bufferIsInfinite"
@change="toggleBufferInfinite"
class="h-3.5 w-3.5 rounded border-gray-300 text-primary-600"
/>
{{ t('admin.accounts.affinityBufferInfinite') }}
</label>
</div>
<input
v-if="!bufferIsInfinite"
:value="buffer"
@input="onBufferInput"
type="number"
min="0"
step="1"
class="input"
:placeholder="t('admin.accounts.affinityBufferPlaceholder')"
/>
<p class="input-hint">{{ t('admin.accounts.affinityBufferHint') }}</p>
</div>
<!-- Zone preview -->
<div v-if="zonePreview && base && base > 0" class="flex items-center gap-2 text-xs">
<span class="inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400">
{{ zonePreview.green }}
</span>
<span v-if="zonePreview.yellow" class="inline-flex items-center gap-1 rounded-full bg-yellow-100 px-2 py-0.5 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400">
{{ zonePreview.yellow }}
</span>
<span v-if="zonePreview.red" class="inline-flex items-center gap-1 rounded-full bg-red-100 px-2 py-0.5 text-red-700 dark:bg-red-900/30 dark:text-red-400">
{{ zonePreview.red }}
</span>
</div>
</div>
</div>
</div>
@@ -782,26 +782,6 @@
</div>
<!-- Client Affinity (Anthropic accounts only) -->
<div
v-if="account?.platform === 'anthropic'"
class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4"
>
<div class="mb-3">
<h3 class="input-label mb-0 text-base font-semibold">{{ t('admin.accounts.affinitySection') }}</h3>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.affinitySectionHint') }}
</p>
</div>
<AffinityConfigCard
:enabled="clientAffinityEnabled"
:base="affinityBase"
:buffer="affinityBuffer"
@update:enabled="clientAffinityEnabled = $event"
@update:base="affinityBase = $event"
@update:buffer="affinityBuffer = $event"
/>
</div>
<div>
<label class="input-label">{{ t('admin.accounts.proxy') }}</label>
<ProxySelector v-model="form.proxy_id" :proxies="proxies" />
@@ -927,6 +907,17 @@
<!-- API Key 账号配额限制 -->
<QuotaLimitCard v-if="account?.type === 'apikey'" :totalLimit="editQuotaLimit" :dailyLimit="editQuotaDailyLimit" :weeklyLimit="editQuotaWeeklyLimit" @update:totalLimit="editQuotaLimit = $event" @update:dailyLimit="editQuotaDailyLimit = $event" @update:weeklyLimit="editQuotaWeeklyLimit = $event" />
<!-- 客户端亲和限制 (all Anthropic account types) -->
<AffinityConfigCard
v-if="account?.platform === 'anthropic'"
:enabled="clientAffinityEnabled"
:base="affinityBase"
:buffer="affinityBuffer"
@update:enabled="clientAffinityEnabled = $event"
@update:base="affinityBase = $event"
@update:buffer="affinityBuffer = $event"
/>
<!-- OpenAI OAuth Codex 官方客户端限制开关 -->
<div
v-if="account?.platform === 'openai' && account?.type === 'oauth'"
@@ -2539,54 +2530,18 @@ const handleSubmit = async () => {
delete newExtra.cache_ttl_override_target
}
// Client affinity setting
if (clientAffinityEnabled.value) {
newExtra.client_affinity_enabled = true
if (affinityBase.value != null && affinityBase.value > 0) {
newExtra.affinity_base = affinityBase.value
} else {
delete newExtra.affinity_base
}
if (affinityBase.value != null && affinityBase.value > 0 && affinityBuffer.value != null) {
newExtra.affinity_buffer = affinityBuffer.value
} else {
delete newExtra.affinity_buffer
}
} else {
delete newExtra.client_affinity_enabled
delete newExtra.affinity_base
delete newExtra.affinity_buffer
}
updatePayload.extra = newExtra
}
// For Anthropic API Key accounts, handle passthrough mode and client affinity in extra
// For Anthropic API Key accounts, handle passthrough mode in extra
if (props.account.platform === 'anthropic' && props.account.type === 'apikey') {
const currentExtra = (props.account.extra as Record<string, unknown>) || {}
const currentExtra = (updatePayload.extra as Record<string, unknown>) || (props.account.extra as Record<string, unknown>) || {}
const newExtra: Record<string, unknown> = { ...currentExtra }
if (anthropicPassthroughEnabled.value) {
newExtra.anthropic_passthrough = true
} else {
delete newExtra.anthropic_passthrough
}
if (clientAffinityEnabled.value) {
newExtra.client_affinity_enabled = true
if (affinityBase.value != null && affinityBase.value > 0) {
newExtra.affinity_base = affinityBase.value
} else {
delete newExtra.affinity_base
}
if (affinityBase.value != null && affinityBase.value > 0 && affinityBuffer.value != null) {
newExtra.affinity_buffer = affinityBuffer.value
} else {
delete newExtra.affinity_buffer
}
} else {
delete newExtra.client_affinity_enabled
delete newExtra.affinity_base
delete newExtra.affinity_buffer
}
updatePayload.extra = newExtra
}
+2 -2
View File
@@ -2025,8 +2025,8 @@ export default {
affinityToggle: 'Enable Client Affinity',
affinityToggleHint: 'New sessions prefer accounts previously used by this client',
affinityBase: 'Base Limit (Green Zone)',
affinityBasePlaceholder: 'e.g. 5',
affinityBaseHint: 'Max number of clients in the green zone (full priority scheduling)',
affinityBasePlaceholder: 'Empty = no limit',
affinityBaseHint: 'Max clients in green zone (full priority). Leave empty for unlimited.',
affinityBuffer: 'Buffer (Yellow Zone)',
affinityBufferPlaceholder: 'e.g. 3',
affinityBufferHint: 'Additional clients allowed in the yellow zone (degraded priority)',
+2 -2
View File
@@ -2159,8 +2159,8 @@ export default {
affinityToggle: '启用客户端亲和',
affinityToggleHint: '新会话优先调度到该客户端之前使用过的账号',
affinityBase: '基础限额(绿区)',
affinityBasePlaceholder: '例如 5',
affinityBaseHint: '绿区最大客户端数量(完整优先级调度)',
affinityBasePlaceholder: '留空表示不限制',
affinityBaseHint: '绿区最大客户端数量(完整优先级调度),留空表示不限制',
affinityBuffer: '缓冲区(黄区)',
affinityBufferPlaceholder: '例如 3',
affinityBufferHint: '黄区允许的额外客户端数量(降级优先级调度)',