Merge PR #672: 支持账号批量配置与可用模型管理

This commit is contained in:
elky
2026-07-18 22:06:32 +08:00
37 changed files with 2612 additions and 377 deletions
@@ -534,6 +534,7 @@ type QuickSelectorValue =
| 'enabled'
type BatchActionValue =
| 'edit_config'
| 'export'
| 'delete'
| 'refresh_oauth'
@@ -574,6 +575,7 @@ const props = defineProps<{
const emit = defineEmits<{
'update:modelValue': [value: boolean]
changed: []
'edit-config': [keyIds: string[]]
}>()
const QUICK_SELECT_OPTIONS: Array<{ value: QuickSelectorValue; label: string }> = [
@@ -590,6 +592,7 @@ const QUICK_SELECT_OPTIONS: Array<{ value: QuickSelectorValue; label: string }>
]
const ACTION_OPTIONS: BatchActionOption[] = [
{ value: 'edit_config', label: '编辑配置', hint: '统一修改支持 API、调度参数与自动获取模型设置。' },
{ value: 'refresh_quota', label: '刷新额度', hint: '调用额度刷新接口,适合核对最新配额状态。' },
{ value: 'refresh_oauth', label: '刷新 OAuth', hint: '仅对 OAuth 账号有效,非 OAuth 账号会自动跳过。' },
{ value: 'set_proxy', label: '配置代理', hint: '为选中账号绑定独立代理节点。' },
@@ -979,6 +982,11 @@ async function confirmAndExecuteAction(action: BatchActionValue): Promise<void>
}
if (!canExecuteSpecifiedAction(action)) return
if (action === 'edit_config') {
await openBatchEditor()
return
}
const actionOption = ACTION_OPTIONS.find((item) => item.value === action)
const actionLabel = actionOption?.label || '执行动作'
const scopeLabel = selectAllFiltered.value ? '筛选结果' : '已选账号'
@@ -992,6 +1000,31 @@ async function confirmAndExecuteAction(action: BatchActionValue): Promise<void>
await executeAction(action)
}
async function openBatchEditor(): Promise<void> {
if (executing.value || selectedCount.value === 0) return
executing.value = true
progressDone.value = 0
progressTotal.value = 0
progressLabel.value = selectAllFiltered.value ? '正在解析筛选结果...' : '正在准备批量编辑...'
try {
const selectedKeys = await resolveSelectedItems()
const keyIds = selectedKeys.map(key => key.key_id)
if (keyIds.length === 0) {
warning('未找到可编辑账号,请刷新列表重试')
return
}
emit('update:modelValue', false)
emit('edit-config', keyIds)
} catch (err) {
showError(parseApiError(err, '准备批量编辑失败'))
} finally {
executing.value = false
progressDone.value = 0
progressTotal.value = 0
progressLabel.value = ''
}
}
const DELETE_POLL_INTERVAL_MS = 2000
const DELETE_POLL_MAX_MS = 10 * 60 * 1000
const DELETE_POLL_MAX_FAILURES = 3
@@ -0,0 +1,666 @@
<template>
<Dialog
:model-value="open"
title="批量编辑密钥"
:description="dialogDescription"
:icon="SquarePen"
size="3xl"
persistent
@update:model-value="handleDialogUpdate"
>
<Tabs v-model="activeTab">
<TabsList class="grid w-full grid-cols-2">
<TabsTrigger value="configuration">
密钥配置
</TabsTrigger>
<TabsTrigger value="models">
可用模型范围
</TabsTrigger>
</TabsList>
<TabsContent
value="configuration"
class="max-h-[min(64vh,40rem)] overflow-y-auto pr-1"
>
<div class="divide-y divide-border/70">
<section class="space-y-3 py-4 first:pt-2">
<label class="flex items-center gap-2 text-sm font-medium">
<Checkbox v-model="form.applyApiFormats" />
<span>支持的 API</span>
</label>
<div
class="grid gap-2 sm:grid-cols-2"
:class="!form.applyApiFormats ? 'pointer-events-none opacity-45' : ''"
>
<label
v-for="format in visibleApiFormats"
:key="format"
class="flex min-h-9 cursor-pointer items-center gap-2 rounded-md border px-3 py-2 text-sm transition-colors hover:bg-muted/40"
:class="form.apiFormats.includes(format) ? 'border-primary/50 bg-primary/5' : 'border-border/70'"
>
<Checkbox
:checked="form.apiFormats.includes(format)"
:disabled="!form.applyApiFormats"
@update:checked="checked => toggleApiFormat(format, checked)"
/>
<span class="truncate">{{ formatApiFormat(format) }}</span>
</label>
<p
v-if="visibleApiFormats.length === 0"
class="text-xs text-muted-foreground sm:col-span-2"
>
当前提供商没有可配置的 API 格式
</p>
</div>
</section>
<section class="space-y-3 py-4">
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<BatchFieldToggle
v-model="form.applyActive"
label="启用状态"
>
<Select
v-model="activeValue"
:disabled="!form.applyActive"
>
<SelectTrigger class="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="enabled">
启用
</SelectItem>
<SelectItem value="disabled">
禁用
</SelectItem>
</SelectContent>
</Select>
</BatchFieldToggle>
<BatchFieldToggle
v-model="form.applyInternalPriority"
label="优先级"
>
<Input
v-model="form.internalPriority"
type="number"
min="0"
class="h-9"
:disabled="!form.applyInternalPriority"
/>
</BatchFieldToggle>
<BatchFieldToggle
v-model="form.applyRpmLimit"
label="RPM 限制"
>
<Input
v-model="form.rpmLimit"
type="number"
min="1"
max="10000"
placeholder="自适应"
class="h-9"
:disabled="!form.applyRpmLimit"
/>
</BatchFieldToggle>
<BatchFieldToggle
v-model="form.applyConcurrentLimit"
label="并发请求上限"
>
<Input
v-model="form.concurrentLimit"
type="number"
min="0"
placeholder="不限制"
class="h-9"
:disabled="!form.applyConcurrentLimit"
/>
</BatchFieldToggle>
<BatchFieldToggle
v-model="form.applyCacheTtl"
label="缓存 TTL"
>
<div class="relative">
<Input
v-model="form.cacheTtlMinutes"
type="number"
min="0"
max="60"
class="h-9 pr-12"
:disabled="!form.applyCacheTtl"
/>
<span class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-xs text-muted-foreground">分钟</span>
</div>
</BatchFieldToggle>
<BatchFieldToggle
v-model="form.applyProbeInterval"
label="熔断探测"
>
<div class="relative">
<Input
v-model="form.maxProbeIntervalMinutes"
type="number"
min="0"
max="32"
class="h-9 pr-12"
:disabled="!form.applyProbeInterval"
/>
<span class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-xs text-muted-foreground">分钟</span>
</div>
</BatchFieldToggle>
</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" />
<span>备注</span>
</label>
<Textarea
v-model="form.note"
rows="3"
placeholder="留空可清除备注"
:disabled="!form.applyNote"
/>
</section>
</div>
</TabsContent>
<TabsContent
value="models"
class="max-h-[min(64vh,40rem)] overflow-y-auto pr-1"
>
<div class="space-y-4 py-1">
<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.applyAllowedModels" />
<span>应用可用模型范围</span>
</label>
<p class="pl-6 text-xs text-muted-foreground">
限制所选账号能够承接的模型;不限制时允许全部模型
</p>
</div>
<div
class="space-y-4"
:class="!form.applyAllowedModels ? 'pointer-events-none opacity-45' : ''"
>
<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">
允许全部模型
</p>
<p class="text-xs text-muted-foreground">
关闭后仅允许下方选中的模型
</p>
</div>
<Switch
v-model="form.unrestrictedModels"
:disabled="!form.applyAllowedModels"
/>
</div>
<div
class="space-y-3"
:class="modelSelectionDisabled ? 'pointer-events-none opacity-45' : ''"
>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
<div class="relative min-w-0 flex-1">
<Search class="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
v-model="modelSearch"
placeholder="搜索或输入自定义模型"
class="h-9 pl-8"
:disabled="modelSelectionDisabled"
/>
</div>
<Button
variant="outline"
class="h-9 shrink-0"
:disabled="fetchingUpstreamModels || modelSelectionDisabled"
@click="fetchUpstreamModels(true)"
>
<RefreshCw
class="mr-2 h-4 w-4"
:class="fetchingUpstreamModels ? 'animate-spin' : ''"
/>
获取上游模型
</Button>
</div>
<div class="flex items-center justify-between gap-3 text-xs text-muted-foreground">
<span>已选择 {{ form.selectedModels.length }} 个模型</span>
<button
v-if="filteredModels.length > 0"
type="button"
class="text-primary hover:underline"
:disabled="modelSelectionDisabled"
@click="toggleFilteredModels"
>
{{ areFilteredModelsSelected ? '取消当前结果' : '选择当前结果' }}
</button>
</div>
<div class="overflow-hidden rounded-md border border-border/70">
<button
v-if="canAddCustomModel"
type="button"
class="flex w-full items-center gap-2 border-b border-dashed px-3 py-2 text-left text-sm hover:bg-muted/40"
:disabled="modelSelectionDisabled"
@click="addCustomModel"
>
<Plus class="h-4 w-4 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate font-mono">{{ normalizedModelSearch }}</span>
<span class="text-xs text-muted-foreground">添加</span>
</button>
<div class="max-h-72 overflow-y-auto">
<label
v-for="model in filteredModels"
:key="model.id"
class="flex cursor-pointer items-center gap-2 border-b border-border/60 px-3 py-2 last:border-b-0 hover:bg-muted/30"
>
<Checkbox
:checked="form.selectedModels.includes(model.id)"
:disabled="modelSelectionDisabled"
@update:checked="checked => toggleModel(model.id, checked)"
/>
<span class="min-w-0 flex-1 truncate font-mono text-sm">{{ model.id }}</span>
<Badge
variant="outline"
class="h-5 shrink-0 px-1.5 text-[10px]"
>
{{ model.source }}
</Badge>
</label>
<div
v-if="loadingModels"
class="flex items-center justify-center py-10 text-muted-foreground"
>
<Loader2 class="h-5 w-5 animate-spin" />
</div>
<div
v-else-if="filteredModels.length === 0"
class="py-10 text-center text-sm text-muted-foreground"
>
暂无匹配模型
</div>
</div>
</div>
<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>
</Tabs>
<template #footer>
<Button
variant="outline"
:disabled="saving"
@click="closeDialog"
>
取消
</Button>
<Button
:disabled="saving"
@click="saveChanges"
>
<Loader2
v-if="saving"
class="mr-2 h-4 w-4 animate-spin"
/>
{{ saving ? '保存中...' : `应用到 ${keyIds.length} 个密钥` }}
</Button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import {
Badge,
Button,
Checkbox,
Dialog,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Switch,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
Textarea,
} from '@/components/ui'
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'
import { useConfirm } from '@/composables/useConfirm'
import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser'
import { useUpstreamModelsCache } from '@/features/providers/composables/useUpstreamModelsCache'
import BatchFieldToggle from './PoolKeyBatchFieldToggle.vue'
import {
buildPoolKeyBatchUpdatePatch,
type PoolKeyBatchEditState,
} from '../utils/poolKeyBatchEdit'
interface ModelOption {
id: string
source: '提供商' | '上游' | '自定义'
}
const props = defineProps<{
open: boolean
providerId: string
providerName?: string
keyIds: string[]
availableApiFormats: string[]
}>()
const emit = defineEmits<{
close: []
saved: []
}>()
const { success, warning, error: showError } = useToast()
const { confirm } = useConfirm()
const { fetchModelsForKeys } = useUpstreamModelsCache()
const activeTab = ref('configuration')
const saving = ref(false)
const loadingProviderModels = ref(false)
const fetchingUpstreamModels = ref(false)
const providerModelIds = ref<string[]>([])
const upstreamModels = ref<UpstreamModel[]>([])
const modelSearch = ref('')
function createInitialForm(): PoolKeyBatchEditState {
return {
applyApiFormats: false,
apiFormats: [],
applyActive: false,
isActive: true,
applyInternalPriority: false,
internalPriority: '0',
applyRpmLimit: false,
rpmLimit: '',
applyConcurrentLimit: false,
concurrentLimit: '',
applyCacheTtl: false,
cacheTtlMinutes: '5',
applyProbeInterval: false,
maxProbeIntervalMinutes: '32',
applyNote: false,
note: '',
applyAutoFetchModels: false,
autoFetchModels: false,
includePatterns: '',
excludePatterns: '',
applyAllowedModels: false,
unrestrictedModels: true,
selectedModels: [],
lockSelectedModels: true,
}
}
const form = reactive<PoolKeyBatchEditState>(createInitialForm())
const dialogDescription = computed(() => {
const providerName = props.providerName?.trim()
const prefix = providerName ? `${providerName} · ` : ''
return `${prefix}已选 ${props.keyIds.length} 个密钥`
})
const visibleApiFormats = computed(() => sortApiFormats(props.availableApiFormats || []))
const loadingModels = computed(() => loadingProviderModels.value || fetchingUpstreamModels.value)
const normalizedModelSearch = computed(() => modelSearch.value.trim())
const modelSelectionDisabled = computed(() => (
!form.applyAllowedModels || form.unrestrictedModels
))
const activeValue = computed({
get: () => form.isActive ? 'enabled' : 'disabled',
set: value => { form.isActive = value === 'enabled' },
})
const allModels = computed<ModelOption[]>(() => {
const byId = new Map<string, ModelOption>()
for (const id of providerModelIds.value) {
const normalized = id.trim()
if (normalized) byId.set(normalized, { id: normalized, source: '提供商' })
}
for (const model of upstreamModels.value) {
const normalized = model.id?.trim()
if (normalized && !byId.has(normalized)) {
byId.set(normalized, { id: normalized, source: '上游' })
}
}
for (const id of form.selectedModels) {
const normalized = id.trim()
if (normalized && !byId.has(normalized)) {
byId.set(normalized, { id: normalized, source: '自定义' })
}
}
return [...byId.values()].sort((a, b) => a.id.localeCompare(b.id))
})
const filteredModels = computed(() => {
const search = normalizedModelSearch.value.toLowerCase()
if (!search) return allModels.value
return allModels.value.filter(model => model.id.toLowerCase().includes(search))
})
const canAddCustomModel = computed(() => {
const model = normalizedModelSearch.value
return Boolean(model) && !allModels.value.some(item => item.id === model)
})
const areFilteredModelsSelected = computed(() => (
filteredModels.value.length > 0
&& filteredModels.value.every(model => form.selectedModels.includes(model.id))
))
function resetDialog(): void {
Object.assign(form, createInitialForm())
activeTab.value = 'configuration'
modelSearch.value = ''
providerModelIds.value = []
upstreamModels.value = []
}
function toggleApiFormat(format: string, checked: boolean): void {
const next = new Set(form.apiFormats)
if (checked) next.add(format)
else next.delete(format)
form.apiFormats = [...next]
}
function toggleModel(modelId: string, checked: boolean): void {
const next = new Set(form.selectedModels)
if (checked) next.add(modelId)
else next.delete(modelId)
form.selectedModels = [...next]
}
function toggleFilteredModels(): void {
const next = new Set(form.selectedModels)
const select = !areFilteredModelsSelected.value
for (const model of filteredModels.value) {
if (select) next.add(model.id)
else next.delete(model.id)
}
form.selectedModels = [...next]
}
function addCustomModel(): void {
const model = normalizedModelSearch.value
if (!model) return
toggleModel(model, true)
modelSearch.value = ''
}
async function loadProviderModels(): Promise<void> {
if (!props.providerId) return
loadingProviderModels.value = true
try {
const models = await getProviderModels(props.providerId, { limit: 1000 })
providerModelIds.value = models
.map(model => model.provider_model_name?.trim())
.filter((model): model is string => Boolean(model))
} catch (err) {
showError(parseApiError(err, '加载提供商模型失败'))
} finally {
loadingProviderModels.value = false
}
}
async function fetchUpstreamModels(forceRefresh = false): Promise<void> {
if (!props.providerId || props.keyIds.length === 0) return
fetchingUpstreamModels.value = true
try {
const result = await fetchModelsForKeys(props.providerId, props.keyIds, forceRefresh)
if (result.error) {
warning(result.error)
return
}
upstreamModels.value = result.models
if (result.warning) warning(result.warning)
else success(`已获取 ${result.models.length} 个上游模型`)
} finally {
fetchingUpstreamModels.value = false
}
}
function handleDialogUpdate(value: boolean): void {
if (!value && !saving.value) closeDialog()
}
function closeDialog(): void {
if (saving.value) return
emit('close')
}
async function saveChanges(): Promise<void> {
if (saving.value) return
if (props.keyIds.length === 0) {
warning('请选择要编辑的密钥')
return
}
const build = buildPoolKeyBatchUpdatePatch(form)
if (!build.patch || build.error) {
warning(build.error || '批量配置无效')
if (form.applyAllowedModels && build.error?.includes('模型')) activeTab.value = 'models'
return
}
const confirmed = await confirm({
title: '应用批量配置',
message: `将对 ${props.keyIds.length} 个密钥修改:${build.fieldLabels.join('、')}。是否继续?`,
confirmText: '确认应用',
})
if (!confirmed) return
saving.value = true
try {
const result = await batchUpdatePoolKeys(props.providerId, {
key_ids: props.keyIds,
patch: build.patch,
})
const modelSync = result.model_sync
if (modelSync?.failed) {
warning(`已更新 ${result.affected} 个密钥,${modelSync.failed} 个账号的模型同步失败`)
} else if (modelSync && modelSync.attempted < modelSync.requested) {
warning(`已更新 ${result.affected} 个密钥,部分账号未执行即时模型同步`)
} else {
success(result.message)
}
emit('saved')
emit('close')
} catch (err) {
showError(parseApiError(err, '批量更新密钥失败'))
} finally {
saving.value = false
}
}
watch(
() => props.open,
open => {
if (!open) return
resetDialog()
void loadProviderModels()
},
)
</script>
@@ -0,0 +1,27 @@
<template>
<div class="min-w-0 space-y-1.5">
<label class="flex items-center gap-2 text-xs font-medium">
<Checkbox
:model-value="modelValue"
@update:model-value="value => emit('update:modelValue', value)"
/>
<span>{{ label }}</span>
</label>
<div :class="!modelValue ? 'pointer-events-none opacity-45' : ''">
<slot />
</div>
</div>
</template>
<script setup lang="ts">
import { Checkbox } from '@/components/ui'
defineProps<{
modelValue: boolean
label: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
</script>
@@ -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: Users },
{ 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:generate_content'],
})
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()
})
})
@@ -39,6 +39,7 @@ describe('PoolManagementHeader', () => {
refreshTitle: '刷新',
onImport: () => events.push('import'),
onScheduling: () => events.push('scheduling'),
onAccountBatch: () => events.push('accountBatch'),
onDemandMetrics: () => events.push('demandMetrics'),
onRefresh: () => events.push('refresh'),
})
@@ -53,10 +54,11 @@ 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()
expect(events).toEqual(['import', 'scheduling', 'demandMetrics', 'refresh'])
expect(events).toEqual(['import', 'scheduling', 'accountBatch', 'demandMetrics', 'refresh'])
expect(root.textContent).toContain('2 维度')
app.unmount()
@@ -0,0 +1,133 @@
import { describe, expect, it } from 'vitest'
import {
buildPoolKeyBatchUpdatePatch,
parsePoolKeyModelPatterns,
type PoolKeyBatchEditState,
} from '../poolKeyBatchEdit'
function state(overrides: Partial<PoolKeyBatchEditState> = {}): PoolKeyBatchEditState {
return {
applyApiFormats: false,
apiFormats: [],
applyActive: false,
isActive: true,
applyInternalPriority: false,
internalPriority: '0',
applyRpmLimit: false,
rpmLimit: '',
applyConcurrentLimit: false,
concurrentLimit: '',
applyCacheTtl: false,
cacheTtlMinutes: '5',
applyProbeInterval: false,
maxProbeIntervalMinutes: '32',
applyNote: false,
note: '',
applyAutoFetchModels: false,
autoFetchModels: false,
includePatterns: '',
excludePatterns: '',
applyAllowedModels: false,
unrestrictedModels: true,
selectedModels: [],
lockSelectedModels: true,
...overrides,
}
}
describe('buildPoolKeyBatchUpdatePatch', () => {
it('only emits fields explicitly enabled by the operator', () => {
const result = buildPoolKeyBatchUpdatePatch(state({
applyApiFormats: true,
apiFormats: ['openai:responses', 'openai:responses', ' openai:chat '],
applyRpmLimit: true,
rpmLimit: '',
}))
expect(result.error).toBeNull()
expect(result.patch).toEqual({
api_formats: ['openai:responses', 'openai:chat'],
rpm_limit: null,
})
})
it('builds an explicit model access range without changing automatic discovery', () => {
const result = buildPoolKeyBatchUpdatePatch(state({
applyAllowedModels: true,
unrestrictedModels: false,
selectedModels: ['gpt-5.6-sol', 'gpt-5.6-sol', 'gpt-5.6-luna'],
lockSelectedModels: false,
}))
expect(result.patch).toEqual({
allowed_models: ['gpt-5.6-sol', 'gpt-5.6-luna'],
locked_models: [],
})
})
it('builds automatic discovery filters independently from the model access range', () => {
const result = buildPoolKeyBatchUpdatePatch(state({
applyAutoFetchModels: true,
autoFetchModels: true,
includePatterns: 'gpt-*,\nclaude-*',
excludePatterns: '*-preview, *-beta',
}))
expect(result.patch).toEqual({
auto_fetch_models: true,
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({
applyApiFormats: true,
})).error).toBe('请至少选择一个支持的 API')
expect(buildPoolKeyBatchUpdatePatch(state({
applyCacheTtl: true,
cacheTtlMinutes: '61',
})).error).toBe('缓存 TTL 必须是 0-60 的整数')
expect(buildPoolKeyBatchUpdatePatch(state({
applyAllowedModels: true,
unrestrictedModels: false,
})).error).toBe('请至少选择一个可用模型')
})
})
describe('parsePoolKeyModelPatterns', () => {
it('normalizes comma and line separated patterns', () => {
expect(parsePoolKeyModelPatterns(' gpt-* , claude-*\ngpt-* ')).toEqual([
'gpt-*',
'claude-*',
])
})
})
@@ -0,0 +1,149 @@
import type { PoolKeyBatchUpdatePatch } from '@/api/endpoints/pool'
export interface PoolKeyBatchEditState {
applyApiFormats: boolean
apiFormats: string[]
applyActive: boolean
isActive: boolean
applyInternalPriority: boolean
internalPriority: string
applyRpmLimit: boolean
rpmLimit: string
applyConcurrentLimit: boolean
concurrentLimit: string
applyCacheTtl: boolean
cacheTtlMinutes: string
applyProbeInterval: boolean
maxProbeIntervalMinutes: string
applyNote: boolean
note: string
applyAutoFetchModels: boolean
autoFetchModels: boolean
includePatterns: string
excludePatterns: string
applyAllowedModels: boolean
unrestrictedModels: boolean
selectedModels: string[]
lockSelectedModels: boolean
}
export interface PoolKeyBatchPatchBuildResult {
patch: PoolKeyBatchUpdatePatch | null
fieldLabels: string[]
error: string | null
}
function uniqueTrimmed(values: string[]): string[] {
return [...new Set(values.map(value => value.trim()).filter(Boolean))]
}
export function parsePoolKeyModelPatterns(value: string): string[] {
return uniqueTrimmed(value.split(/[,\n]/))
}
function parseIntegerField(
value: string,
label: string,
min: number,
max?: number,
nullable = false,
): { value?: number | null; error?: string } {
const normalized = value.trim()
if (!normalized) {
return nullable ? { value: null } : { error: `${label} 不能为空` }
}
const parsed = Number(normalized)
if (!Number.isInteger(parsed) || parsed < min || (max !== undefined && parsed > max)) {
const range = max === undefined ? `不小于 ${min}` : `${min}-${max}`
return { error: `${label} 必须是 ${range} 的整数` }
}
return { value: parsed }
}
export function buildPoolKeyBatchUpdatePatch(
state: PoolKeyBatchEditState,
): PoolKeyBatchPatchBuildResult {
const patch: PoolKeyBatchUpdatePatch = {}
const fieldLabels: string[] = []
if (state.applyApiFormats) {
const apiFormats = uniqueTrimmed(state.apiFormats)
if (apiFormats.length === 0) {
return { patch: null, fieldLabels, error: '请至少选择一个支持的 API' }
}
patch.api_formats = apiFormats
fieldLabels.push('支持 API')
}
if (state.applyActive) {
patch.is_active = state.isActive
fieldLabels.push('启用状态')
}
if (state.applyInternalPriority) {
const parsed = parseIntegerField(state.internalPriority, '优先级', 0)
if (parsed.error) return { patch: null, fieldLabels, error: parsed.error }
patch.internal_priority = parsed.value as number
fieldLabels.push('优先级')
}
if (state.applyRpmLimit) {
const parsed = parseIntegerField(state.rpmLimit, 'RPM 限制', 1, 10000, true)
if (parsed.error) return { patch: null, fieldLabels, error: parsed.error }
patch.rpm_limit = parsed.value
fieldLabels.push('RPM 限制')
}
if (state.applyConcurrentLimit) {
const parsed = parseIntegerField(state.concurrentLimit, '并发请求上限', 0, undefined, true)
if (parsed.error) return { patch: null, fieldLabels, error: parsed.error }
patch.concurrent_limit = parsed.value
fieldLabels.push('并发请求上限')
}
if (state.applyCacheTtl) {
const parsed = parseIntegerField(state.cacheTtlMinutes, '缓存 TTL', 0, 60)
if (parsed.error) return { patch: null, fieldLabels, error: parsed.error }
patch.cache_ttl_minutes = parsed.value as number
fieldLabels.push('缓存 TTL')
}
if (state.applyProbeInterval) {
const parsed = parseIntegerField(state.maxProbeIntervalMinutes, '熔断探测', 0, 32)
if (parsed.error) return { patch: null, fieldLabels, error: parsed.error }
patch.max_probe_interval_minutes = parsed.value as number
fieldLabels.push('熔断探测')
}
if (state.applyNote) {
patch.note = state.note.trim() || null
fieldLabels.push('备注')
}
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('自动获取上游可用模型')
}
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) {
return { patch: null, fieldLabels, error: '请至少启用一个批量编辑字段' }
}
return { patch, fieldLabels, error: null }
}