fix(pool): 对齐账号批量配置语义

This commit is contained in:
MMEXA
2026-07-14 08:07:28 +08:00
parent 0acd1de29c
commit 01e104d86a
8 changed files with 227 additions and 118 deletions
@@ -1,7 +1,7 @@
<template>
<Dialog
:model-value="modelValue"
title="密钥批量管理"
title="账号批量操作"
:description="dialogDescription"
size="3xl"
persistent
@@ -372,7 +372,7 @@ const QUICK_SELECT_OPTIONS: Array<{ value: QuickSelectorValue; label: string }>
]
const ACTION_OPTIONS: BatchActionOption[] = [
{ value: 'edit_config', label: '编辑配置', hint: '统一修改支持 API、调度参数与模型权限。' },
{ value: 'edit_config', label: '编辑配置', hint: '统一修改支持 API、调度参数与自动获取模型设置。' },
{ value: 'refresh_quota', label: '刷新额度', hint: '调用额度刷新接口,适合核对最新配额状态。' },
{ value: 'refresh_oauth', label: '刷新 OAuth', hint: '仅对 OAuth 账号有效,非 OAuth 账号会自动跳过。' },
{ value: 'set_proxy', label: '配置代理', hint: '为选中账号绑定独立代理节点。' },
@@ -3,7 +3,7 @@
:model-value="open"
title="批量编辑密钥"
:description="dialogDescription"
:icon="ListChecks"
:icon="SquarePen"
size="3xl"
persistent
@update:model-value="handleDialogUpdate"
@@ -14,7 +14,7 @@
密钥配置
</TabsTrigger>
<TabsTrigger value="models">
模型权限
可用模型范围
</TabsTrigger>
</TabsList>
@@ -156,6 +156,57 @@
</div>
</section>
<section class="space-y-3 py-4">
<BatchFieldToggle
v-model="form.applyAutoFetchModels"
label="应用自动获取设置"
>
<div class="space-y-3 rounded-md border border-border/60 bg-muted/30 px-3 py-3">
<div class="flex items-center justify-between gap-4">
<div class="space-y-0.5">
<Label class="text-sm font-medium">自动获取上游可用模型</Label>
<p class="text-xs text-muted-foreground">
定时更新上游模型配合模型映射使用
</p>
</div>
<Switch
v-model="form.autoFetchModels"
:disabled="!form.applyAutoFetchModels"
/>
</div>
<div
v-if="form.autoFetchModels"
class="space-y-2 border-t border-border/40 pt-3"
>
<div class="grid gap-3 sm:grid-cols-2">
<div class="space-y-1.5">
<Label class="text-xs">包含规则</Label>
<Input
v-model="form.includePatterns"
placeholder="gpt-*, claude-*, 留空包含全部"
class="h-9"
:disabled="!form.applyAutoFetchModels"
/>
</div>
<div class="space-y-1.5">
<Label class="text-xs">排除规则</Label>
<Input
v-model="form.excludePatterns"
placeholder="*-preview, *-beta"
class="h-9"
:disabled="!form.applyAutoFetchModels"
/>
</div>
</div>
<p class="text-xs text-muted-foreground">
逗号分隔支持 * ? 通配符不区分大小写
</p>
</div>
</div>
</BatchFieldToggle>
</section>
<section class="space-y-3 py-4">
<label class="flex items-center gap-2 text-sm font-medium">
<Checkbox v-model="form.applyNote" />
@@ -176,38 +227,21 @@
class="max-h-[min(64vh,40rem)] overflow-y-auto pr-1"
>
<div class="space-y-4 py-1">
<div class="flex flex-col gap-3 border-b border-border/70 pb-4 sm:flex-row sm:items-center sm:justify-between">
<div class="space-y-1 border-b border-border/70 pb-4">
<label class="flex items-center gap-2 text-sm font-medium">
<Checkbox v-model="form.applyModels" />
<span>批量管理模型权限</span>
<Checkbox v-model="form.applyAllowedModels" />
<span>应用可用模型范围</span>
</label>
<Select
v-model="form.modelMode"
:disabled="!form.applyModels"
>
<SelectTrigger class="h-9 w-full sm:w-48">
<SelectValue placeholder="选择管理方式" />
</SelectTrigger>
<SelectContent>
<SelectItem value="manual">
手动权限
</SelectItem>
<SelectItem value="automatic">
自动发现
</SelectItem>
</SelectContent>
</Select>
<p class="pl-6 text-xs text-muted-foreground">
限制所选账号能够承接的模型不限制时允许全部模型
</p>
</div>
<div
v-if="form.modelMode"
class="space-y-4"
:class="!form.applyModels ? 'pointer-events-none opacity-45' : ''"
:class="!form.applyAllowedModels ? 'pointer-events-none opacity-45' : ''"
>
<div
v-if="form.modelMode === 'manual'"
class="flex items-center justify-between gap-4 border-b border-border/70 pb-4"
>
<div class="flex items-center justify-between gap-4 border-b border-border/70 pb-4">
<div class="min-w-0">
<p class="text-sm font-medium">
允许全部模型
@@ -218,34 +252,10 @@
</div>
<Switch
v-model="form.unrestrictedModels"
:disabled="!form.applyModels"
:disabled="!form.applyAllowedModels"
/>
</div>
<div
v-else
class="grid gap-3 border-b border-border/70 pb-4 sm:grid-cols-2"
>
<div class="space-y-1.5">
<Label class="text-xs">包含规则</Label>
<Input
v-model="form.includePatterns"
placeholder="gpt-*, claude-*"
class="h-9"
:disabled="!form.applyModels"
/>
</div>
<div class="space-y-1.5">
<Label class="text-xs">排除规则</Label>
<Input
v-model="form.excludePatterns"
placeholder="*-preview, *-beta"
class="h-9"
:disabled="!form.applyModels"
/>
</div>
</div>
<div
class="space-y-3"
:class="modelSelectionDisabled ? 'pointer-events-none opacity-45' : ''"
@@ -275,9 +285,7 @@
</div>
<div class="flex items-center justify-between gap-3 text-xs text-muted-foreground">
<span>
{{ form.modelMode === 'automatic' ? '已锁定' : '已允许' }} {{ form.selectedModels.length }} 个模型
</span>
<span>已选择 {{ form.selectedModels.length }} 个模型</span>
<button
v-if="filteredModels.length > 0"
type="button"
@@ -334,14 +342,22 @@
</div>
</div>
</div>
</div>
</div>
<div
v-else
class="py-12 text-center text-sm text-muted-foreground"
>
选择模型权限管理方式后继续
<div class="flex items-center justify-between gap-4 rounded-md border border-border/60 bg-muted/30 px-3 py-2.5">
<div class="min-w-0">
<p class="text-sm font-medium">
锁定已选模型
</p>
<p class="text-xs text-muted-foreground">
自动获取开启时锁定的模型不会被同步移除
</p>
</div>
<Switch
v-model="form.lockSelectedModels"
:disabled="modelSelectionDisabled"
/>
</div>
</div>
</div>
</div>
</TabsContent>
@@ -390,7 +406,7 @@ import {
TabsTrigger,
Textarea,
} from '@/components/ui'
import { ListChecks, Loader2, Plus, RefreshCw, Search } from 'lucide-vue-next'
import { Loader2, Plus, RefreshCw, Search, SquarePen } from 'lucide-vue-next'
import { getProviderModels } from '@/api/endpoints/models'
import { batchUpdatePoolKeys } from '@/api/endpoints/pool'
import { formatApiFormat, sortApiFormats, type UpstreamModel } from '@/api/endpoints/types'
@@ -452,12 +468,14 @@ function createInitialForm(): PoolKeyBatchEditState {
maxProbeIntervalMinutes: '32',
applyNote: false,
note: '',
applyModels: false,
modelMode: '',
unrestrictedModels: true,
selectedModels: [],
applyAutoFetchModels: false,
autoFetchModels: false,
includePatterns: '',
excludePatterns: '',
applyAllowedModels: false,
unrestrictedModels: true,
selectedModels: [],
lockSelectedModels: true,
}
}
@@ -472,7 +490,7 @@ const visibleApiFormats = computed(() => sortApiFormats(props.availableApiFormat
const loadingModels = computed(() => loadingProviderModels.value || fetchingUpstreamModels.value)
const normalizedModelSearch = computed(() => modelSearch.value.trim())
const modelSelectionDisabled = computed(() => (
!form.applyModels || (form.modelMode === 'manual' && form.unrestrictedModels)
!form.applyAllowedModels || form.unrestrictedModels
))
const activeValue = computed({
get: () => form.isActive ? 'enabled' : 'disabled',
@@ -603,7 +621,7 @@ async function saveChanges(): Promise<void> {
const build = buildPoolKeyBatchUpdatePatch(form)
if (!build.patch || build.error) {
warning(build.error || '批量配置无效')
if (form.applyModels && build.error?.includes('模型')) activeTab.value = 'models'
if (form.applyAllowedModels && build.error?.includes('模型')) activeTab.value = 'models'
return
}
@@ -249,13 +249,13 @@ import {
Activity,
ChevronDown,
Edit,
ListChecks,
Plug,
Power,
Search,
Settings2,
SlidersHorizontal,
Upload,
Users,
} from 'lucide-vue-next'
import {
Button,
@@ -369,7 +369,7 @@ const mobileActions = computed(() => {
{ key: 'import', title: legacyT('添加账号'), event: 'import', icon: Upload },
{ key: 'providerProxy' },
{ key: 'scheduling', title: legacyT('号池调度'), event: 'scheduling', icon: SlidersHorizontal },
{ key: 'accountBatch', title: legacyT('密钥批量管理'), event: 'accountBatch', icon: ListChecks },
{ key: 'accountBatch', title: legacyT('账号批量操作'), event: 'accountBatch', icon: Users },
{ key: 'editProvider', title: legacyT('编辑提供商'), event: 'editProvider', icon: Edit },
{ key: 'editEndpoint', title: legacyT('编辑端点'), event: 'editEndpoint', icon: Plug },
]
@@ -394,7 +394,7 @@ const desktopPostProxyActions = computed<HeaderAction[]>(() => {
}
actions.push(
{ key: 'advanced', title: legacyT('高级设置'), event: 'advanced', icon: Settings2 },
{ key: 'accountBatch', title: legacyT('密钥批量管理'), event: 'accountBatch', icon: ListChecks },
{ key: 'accountBatch', title: legacyT('账号批量操作'), event: 'accountBatch', icon: Users },
{ key: 'toggleProvider', title: props.providerToggleButtonTitle, event: 'toggleProvider', icon: Power },
)
return actions
@@ -0,0 +1,71 @@
import { createApp, nextTick } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import PoolKeyBatchEditDialog from '../PoolKeyBatchEditDialog.vue'
vi.mock('@/api/endpoints/models', () => ({
getProviderModels: vi.fn().mockResolvedValue([]),
}))
vi.mock('@/api/endpoints/pool', () => ({
batchUpdatePoolKeys: vi.fn(),
}))
vi.mock('@/features/providers/composables/useUpstreamModelsCache', () => ({
useUpstreamModelsCache: () => ({
fetchModelsForKeys: vi.fn().mockResolvedValue({ models: [] }),
}),
}))
vi.mock('@/composables/useConfirm', () => ({
useConfirm: () => ({ confirm: vi.fn().mockResolvedValue(true) }),
}))
vi.mock('@/composables/useToast', () => ({
useToast: () => ({
success: vi.fn(),
warning: vi.fn(),
error: vi.fn(),
}),
}))
describe('PoolKeyBatchEditDialog', () => {
it('uses the same automatic model discovery language as the single-key editor', async () => {
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(PoolKeyBatchEditDialog, {
open: true,
providerId: 'provider-1',
providerName: 'Google API',
keyIds: ['key-1', 'key-2'],
availableApiFormats: ['gemini:chat'],
})
app.mount(root)
await nextTick()
const applyLabel = [...document.body.querySelectorAll('label')]
.find(label => label.textContent?.includes('应用自动获取设置'))
const applyCheckbox = applyLabel?.querySelector<HTMLInputElement>('input[type="checkbox"]')
expect(applyCheckbox).toBeTruthy()
if (applyCheckbox) {
applyCheckbox.checked = true
applyCheckbox.dispatchEvent(new Event('change', { bubbles: true }))
}
await nextTick()
document.body.querySelector<HTMLButtonElement>('[role="switch"]')?.click()
await nextTick()
const text = document.body.textContent || ''
expect(text).toContain('自动获取上游可用模型')
expect(text).toContain('包含规则')
expect(text).toContain('排除规则')
expect(text).toContain('可用模型范围')
expect(text).not.toContain('模型权限')
expect(text).not.toContain('自动发现')
app.unmount()
root.remove()
})
})
@@ -54,7 +54,7 @@ describe('PoolManagementHeader', () => {
root.querySelector<HTMLButtonElement>('[title="添加账号"]')?.click()
root.querySelector<HTMLButtonElement>('[title="点击调整号池调度"]')?.click()
root.querySelector<HTMLButtonElement>('[title="密钥批量管理"]')?.click()
root.querySelector<HTMLButtonElement>('[title="账号批量操作"]')?.click()
root.querySelector<HTMLButtonElement>('[title="查看自适应热池指标"]')?.click()
root.querySelector<HTMLButtonElement>('[title="刷新"]')?.click()
@@ -23,12 +23,14 @@ function state(overrides: Partial<PoolKeyBatchEditState> = {}): PoolKeyBatchEdit
maxProbeIntervalMinutes: '32',
applyNote: false,
note: '',
applyModels: false,
modelMode: '',
unrestrictedModels: true,
selectedModels: [],
applyAutoFetchModels: false,
autoFetchModels: false,
includePatterns: '',
excludePatterns: '',
applyAllowedModels: false,
unrestrictedModels: true,
selectedModels: [],
lockSelectedModels: true,
...overrides,
}
}
@@ -49,40 +51,62 @@ describe('buildPoolKeyBatchUpdatePatch', () => {
})
})
it('builds a manual model policy and preserves explicit restrictions while disabling discovery', () => {
it('builds an explicit model access range without changing automatic discovery', () => {
const result = buildPoolKeyBatchUpdatePatch(state({
applyModels: true,
modelMode: 'manual',
applyAllowedModels: true,
unrestrictedModels: false,
selectedModels: ['gpt-5.6-sol', 'gpt-5.6-sol', 'gpt-5.6-luna'],
lockSelectedModels: false,
}))
expect(result.patch).toEqual({
auto_fetch_models: false,
allowed_models: ['gpt-5.6-sol', 'gpt-5.6-luna'],
locked_models: [],
model_include_patterns: [],
model_exclude_patterns: [],
})
})
it('builds automatic discovery filters and locked models', () => {
it('builds automatic discovery filters independently from the model access range', () => {
const result = buildPoolKeyBatchUpdatePatch(state({
applyModels: true,
modelMode: 'automatic',
selectedModels: ['gpt-5.6-sol'],
applyAutoFetchModels: true,
autoFetchModels: true,
includePatterns: 'gpt-*,\nclaude-*',
excludePatterns: '*-preview, *-beta',
}))
expect(result.patch).toEqual({
auto_fetch_models: true,
locked_models: ['gpt-5.6-sol'],
model_include_patterns: ['gpt-*', 'claude-*'],
model_exclude_patterns: ['*-preview', '*-beta'],
})
})
it('disables automatic discovery without rewriting model filters or access limits', () => {
const result = buildPoolKeyBatchUpdatePatch(state({
applyAutoFetchModels: true,
autoFetchModels: false,
includePatterns: 'gpt-*',
excludePatterns: '*-preview',
}))
expect(result.patch).toEqual({
auto_fetch_models: false,
})
})
it('locks selected models only when the operator enables locking', () => {
const result = buildPoolKeyBatchUpdatePatch(state({
applyAllowedModels: true,
unrestrictedModels: false,
selectedModels: ['gpt-5.6-sol'],
lockSelectedModels: true,
}))
expect(result.patch).toEqual({
allowed_models: ['gpt-5.6-sol'],
locked_models: ['gpt-5.6-sol'],
})
})
it('rejects empty fields and invalid ranges before the request is sent', () => {
expect(buildPoolKeyBatchUpdatePatch(state()).error).toBe('请至少启用一个批量编辑字段')
expect(buildPoolKeyBatchUpdatePatch(state({
@@ -93,10 +117,9 @@ describe('buildPoolKeyBatchUpdatePatch', () => {
cacheTtlMinutes: '61',
})).error).toBe('缓存 TTL 必须是 0-60 的整数')
expect(buildPoolKeyBatchUpdatePatch(state({
applyModels: true,
modelMode: 'manual',
applyAllowedModels: true,
unrestrictedModels: false,
})).error).toBe('请至少选择一个允许的模型')
})).error).toBe('请至少选择一个可用模型')
})
})
@@ -1,7 +1,5 @@
import type { PoolKeyBatchUpdatePatch } from '@/api/endpoints/pool'
export type PoolKeyBatchModelMode = '' | 'manual' | 'automatic'
export interface PoolKeyBatchEditState {
applyApiFormats: boolean
apiFormats: string[]
@@ -19,12 +17,14 @@ export interface PoolKeyBatchEditState {
maxProbeIntervalMinutes: string
applyNote: boolean
note: string
applyModels: boolean
modelMode: PoolKeyBatchModelMode
unrestrictedModels: boolean
selectedModels: string[]
applyAutoFetchModels: boolean
autoFetchModels: boolean
includePatterns: string
excludePatterns: string
applyAllowedModels: boolean
unrestrictedModels: boolean
selectedModels: string[]
lockSelectedModels: boolean
}
export interface PoolKeyBatchPatchBuildResult {
@@ -120,27 +120,25 @@ export function buildPoolKeyBatchUpdatePatch(
fieldLabels.push('备注')
}
if (state.applyModels) {
if (!state.modelMode) {
return { patch: null, fieldLabels, error: '请选择模型权限管理方式' }
}
const selectedModels = uniqueTrimmed(state.selectedModels)
if (state.modelMode === 'manual') {
if (!state.unrestrictedModels && selectedModels.length === 0) {
return { patch: null, fieldLabels, error: '请至少选择一个允许的模型' }
}
patch.auto_fetch_models = false
patch.allowed_models = state.unrestrictedModels ? null : selectedModels
patch.locked_models = []
patch.model_include_patterns = []
patch.model_exclude_patterns = []
} else {
patch.auto_fetch_models = true
patch.locked_models = selectedModels
if (state.applyAutoFetchModels) {
patch.auto_fetch_models = state.autoFetchModels
if (state.autoFetchModels) {
patch.model_include_patterns = parsePoolKeyModelPatterns(state.includePatterns)
patch.model_exclude_patterns = parsePoolKeyModelPatterns(state.excludePatterns)
}
fieldLabels.push('模型权限')
fieldLabels.push('自动获取上游可用模型')
}
if (state.applyAllowedModels) {
const selectedModels = uniqueTrimmed(state.selectedModels)
if (!state.unrestrictedModels && selectedModels.length === 0) {
return { patch: null, fieldLabels, error: '请至少选择一个可用模型' }
}
patch.allowed_models = state.unrestrictedModels ? null : selectedModels
patch.locked_models = state.unrestrictedModels || !state.lockSelectedModels
? []
: selectedModels
fieldLabels.push('可用模型范围')
}
if (fieldLabels.length === 0) {
-1
View File
@@ -1207,7 +1207,6 @@ const legacyExactEnglishMessages: Record<string, string> = {
'模型管理': 'Model management',
'提供商管理': 'Provider management',
'号池管理': 'Pool management',
'密钥批量管理': 'Bulk key management',
'模块管理': 'Module management',
'系统扩展': 'System extensions',
'Aether 提供高可插入的模块化管理机制,帮助连接外部服务和授权系统。': 'Aether provides a highly pluggable module management system for connecting external services and authorization systems.',