feat(providers): add multi-select batch delete for provider models

Let admins select one or more models in a provider's model list and
delete them together, using the existing single-model delete API and
the same confirm-danger pattern as global model batch delete.
This commit is contained in:
Kayphoon
2026-09-14 08:03:59 +00:00
parent 60b89cc840
commit dfe88e34e7
5 changed files with 437 additions and 19 deletions
@@ -2,19 +2,58 @@
<Card class="overflow-hidden">
<!-- 标题头部 -->
<div class="p-4 border-b border-border/60">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold flex items-center gap-2">
模型列表
</h3>
<Button
variant="outline"
size="sm"
class="h-8"
@click="openBatchAssignDialog"
>
<Layers class="w-3.5 h-3.5 mr-1.5" />
关联模型
</Button>
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-2 min-w-0">
<Checkbox
v-if="!isLoading && models.length > 0"
data-testid="models-tab-select-all"
class="shrink-0"
:checked="isAllSelected"
:indeterminate="isPartiallySelected"
:aria-label="selectAllLabel"
:title="selectAllLabel"
@update:checked="toggleSelectAll"
/>
<h3 class="text-sm font-semibold flex items-center gap-2">
模型列表
</h3>
<span
v-if="selectedCount > 0"
class="text-xs text-muted-foreground tabular-nums"
>
已选 {{ selectedCount }}
</span>
</div>
<div class="flex items-center gap-2 shrink-0">
<Button
v-if="selectedCount > 0"
variant="destructive"
size="sm"
class="h-8"
data-testid="models-tab-delete-selected"
:disabled="deletingSelected"
@click="confirmDeleteSelected"
>
<Loader2
v-if="deletingSelected"
class="w-3.5 h-3.5 mr-1.5 animate-spin"
/>
<Trash2
v-else
class="w-3.5 h-3.5 mr-1.5"
/>
{{ deletingSelected ? '删除中...' : '删除选中' }}
</Button>
<Button
variant="outline"
size="sm"
class="h-8"
@click="openBatchAssignDialog"
>
<Layers class="w-3.5 h-3.5 mr-1.5" />
关联模型
</Button>
</div>
</div>
</div>
@@ -44,10 +83,19 @@
<tr
v-for="model in paginatedModels"
:key="model.id"
class="border-b border-border/40 last:border-b-0 hover:bg-muted/30 transition-colors"
class="border-b border-border/40 last:border-b-0 transition-colors"
:class="isModelSelected(model.id) ? 'bg-primary/5' : 'hover:bg-muted/30'"
>
<td class="align-top px-4 py-3">
<div class="flex items-center gap-2.5">
<Checkbox
class="shrink-0"
:data-testid="`models-tab-row-checkbox-${model.id}`"
:checked="isModelSelected(model.id)"
:aria-label="`选择 ${model.global_model_display_name || model.provider_model_name}`"
@click.stop
@update:checked="checked => toggleModelSelection(model.id, checked)"
/>
<!-- 状态指示灯 -->
<div
class="w-2 h-2 rounded-full shrink-0"
@@ -171,7 +219,12 @@
v-if="shouldPaginateModels"
class="px-4 py-2 border-t border-border/40 flex items-center justify-between text-xs text-muted-foreground"
>
<span> {{ sortedModels.length }} 个模型</span>
<span>
{{ sortedModels.length }} 个模型
<template v-if="selectedCount > 0">
· 已选 {{ selectedCount }}
</template>
</span>
<div class="flex items-center gap-1.5">
<Button
variant="ghost"
@@ -251,18 +304,21 @@
import { ref, computed, watch } from 'vue'
import { useSmartPagination } from '@/composables/useSmartPagination'
import { useModelTest } from '@/composables/useModelTest'
import { Box, Edit, Layers, Power, Copy, Loader2, Play } from 'lucide-vue-next'
import { Box, Edit, Layers, Power, Copy, Loader2, Play, Trash2 } from 'lucide-vue-next'
import Card from '@/components/ui/card.vue'
import Button from '@/components/ui/button.vue'
import Checkbox from '@/components/ui/checkbox.vue'
import { useToast } from '@/composables/useToast'
import { useConfirm } from '@/composables/useConfirm'
import { useClipboard } from '@/composables/useClipboard'
import { useI18n } from '@/i18n'
import { sortResolutionEntries } from '@/utils/form'
import {
type Model,
type ProviderEndpoint,
} from '@/api/endpoints'
import { getProviderKeys, type EndpointAPIKey } from '@/api/endpoints/keys'
import { updateModel } from '@/api/endpoints/models'
import { deleteModel, updateModel } from '@/api/endpoints/models'
import { parseApiError } from '@/utils/errorParser'
import { formatApiFormat } from '@/api/endpoints/types/api-format'
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
@@ -296,7 +352,9 @@ const emit = defineEmits<{
}>()
const { error: showError, success: showSuccess } = useToast()
const { confirmDanger } = useConfirm()
const { copyToClipboard } = useClipboard()
const { legacyT } = useI18n()
// 模型测试 composable
const modelTest = useModelTest({ providerId: () => props.provider.id })
@@ -305,6 +363,8 @@ const modelTest = useModelTest({ providerId: () => props.provider.id })
const localLoading = ref(false)
const localModels = ref<Model[]>([])
const togglingModelId = ref<string | null>(null)
const selectedIds = ref<Set<string>>(new Set())
const deletingSelected = ref(false)
const pendingTestModel = ref<Model | null>(null)
const selectedTestEndpoint = ref<ProviderEndpoint | null>(null)
const testRequestHeadersDraft = ref('')
@@ -385,6 +445,16 @@ const sortedModels = computed(() => {
})
})
const selectedCount = computed(() => selectedIds.value.size)
const isAllSelected = computed(() => (
sortedModels.value.length > 0
&& sortedModels.value.every(model => selectedIds.value.has(model.id))
))
const isPartiallySelected = computed(() => (
selectedCount.value > 0 && !isAllSelected.value
))
const selectAllLabel = computed(() => (isAllSelected.value ? '取消全选' : '全选'))
// ===== 模型列表智能分页 =====
const modelsListRef = ref<HTMLElement | null>(null)
const {
@@ -519,6 +589,71 @@ function openBatchAssignDialog() {
emit('batchAssign')
}
function isModelSelected(modelId: string): boolean {
return selectedIds.value.has(modelId)
}
function toggleModelSelection(modelId: string, checked?: boolean) {
const next = new Set(selectedIds.value)
const shouldSelect = checked ?? !next.has(modelId)
if (shouldSelect) {
next.add(modelId)
} else {
next.delete(modelId)
}
selectedIds.value = next
}
function toggleSelectAll(checked: boolean) {
selectedIds.value = checked
? new Set(sortedModels.value.map(model => model.id))
: new Set()
}
function pruneMissingSelection(validIds: Set<string>) {
if (selectedIds.value.size === 0) return
const next = new Set([...selectedIds.value].filter(id => validIds.has(id)))
if (next.size !== selectedIds.value.size) {
selectedIds.value = next
}
}
async function confirmDeleteSelected() {
const ids = Array.from(selectedIds.value)
if (ids.length === 0 || deletingSelected.value) return
const confirmed = await confirmDanger(
legacyT(`确定删除选中的 ${ids.length} 个模型吗?\n\n此操作不可撤销。`),
legacyT('批量删除模型'),
)
if (!confirmed) return
deletingSelected.value = true
try {
const results = await Promise.allSettled(
ids.map(id => deleteModel(props.provider.id, id)),
)
const successCount = results.filter(result => result.status === 'fulfilled').length
const failedCount = results.length - successCount
if (successCount > 0) {
showSuccess(legacyT(`成功删除 ${successCount} 个模型`))
}
if (failedCount > 0) {
showError(legacyT(`${failedCount} 个模型删除失败`), legacyT('部分失败'))
}
selectedIds.value = new Set()
if (successCount > 0) {
emit('refresh')
}
} catch (err: unknown) {
showError(parseApiError(err, '批量删除失败'), '错误')
} finally {
deletingSelected.value = false
}
}
// 切换模型启用状态
async function toggleModelActive(model: Model) {
if (togglingModelId.value) return
@@ -756,6 +891,14 @@ watch(
modelTestProviderKeys.value = []
modelTestKeysLoadedProviderId.value = null
selectedTestKeyIds.value = []
selectedIds.value = new Set()
},
)
watch(
() => models.value.map(model => model.id),
(ids) => {
pruneMissingSelection(new Set(ids))
},
)
@@ -0,0 +1,265 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h, nextTick, reactive, type App } from 'vue'
import ModelsTab from '../ModelsTab.vue'
import { createI18n } from '@/i18n'
import type { Model, ProviderWithEndpointsSummary } from '@/api/endpoints'
const modelMocks = vi.hoisted(() => ({
deleteModel: vi.fn(),
updateModel: vi.fn(),
}))
const confirmMocks = vi.hoisted(() => ({
confirmDanger: vi.fn(),
}))
const toastMocks = vi.hoisted(() => ({
success: vi.fn(),
error: vi.fn(),
}))
vi.mock('@/api/endpoints/models', () => modelMocks)
vi.mock('@/api/endpoints/keys', () => ({
getProviderKeys: vi.fn().mockResolvedValue([]),
}))
vi.mock('@/composables/useConfirm', () => ({
useConfirm: () => confirmMocks,
}))
vi.mock('@/composables/useToast', () => ({
useToast: () => toastMocks,
}))
vi.mock('@/composables/useClipboard', () => ({
useClipboard: () => ({ copyToClipboard: vi.fn() }),
}))
vi.mock('@/composables/useModelTest', () => ({
useModelTest: () => ({
testing: { value: false },
dialogOpen: { value: false },
testResult: { value: null },
testMode: { value: 'global' },
testTrace: { value: null },
requestId: { value: null },
resetState: vi.fn(),
startTest: vi.fn(),
stopPolling: vi.fn(),
}),
}))
vi.mock('../ModelTestDialog.vue', () => ({
default: defineComponent({
name: 'ModelTestDialogStub',
setup: () => () => null,
}),
}))
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
function createProvider(id = 'provider-1'): ProviderWithEndpointsSummary {
return {
id,
name: 'OpenAI Responses',
provider_type: 'custom',
provider_priority: 1,
keep_priority_on_conversion: false,
enable_format_conversion: false,
is_active: true,
total_endpoints: 1,
active_endpoints: 1,
total_keys: 1,
active_keys: 1,
total_models: 2,
active_models: 2,
global_model_ids: [],
avg_health_score: 1,
unhealthy_endpoints: 0,
api_formats: ['openai:chat'],
endpoint_health_details: [],
ops_configured: false,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
}
}
function createModel(overrides: Partial<Model> = {}): Model {
return {
id: 'model-1',
provider_id: 'provider-1',
global_model_id: 'gm-1',
provider_model_name: 'deepseek-v4-flash',
global_model_name: 'deepseek-v4-flash',
global_model_display_name: 'DeepSeek V4 Flash',
is_active: true,
is_available: true,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
...overrides,
}
}
const sampleModels = [
createModel(),
createModel({
id: 'model-2',
provider_model_name: 'kimi-k3',
global_model_name: 'kimi-k3',
global_model_display_name: 'Kimi K3',
}),
]
async function settle() {
for (let index = 0; index < 5; index += 1) {
await Promise.resolve()
await nextTick()
}
}
function mountTab(options?: {
models?: Model[]
provider?: ProviderWithEndpointsSummary
}) {
const root = document.createElement('div')
document.body.appendChild(root)
const state = reactive({
models: options?.models ?? sampleModels,
provider: options?.provider ?? createProvider(),
})
const onRefresh = vi.fn()
const onBatchAssign = vi.fn()
const app = createApp(defineComponent({
setup() {
return () => h(ModelsTab, {
provider: state.provider,
models: state.models,
endpoints: [],
onRefresh,
onBatchAssign,
})
},
}))
app.use(createI18n())
app.mount(root)
mountedApps.push({ app, root })
return { root, state, onRefresh }
}
beforeEach(() => {
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
modelMocks.deleteModel.mockReset()
modelMocks.deleteModel.mockResolvedValue({ message: 'ok' })
modelMocks.updateModel.mockReset()
confirmMocks.confirmDanger.mockReset()
confirmMocks.confirmDanger.mockResolvedValue(true)
toastMocks.success.mockReset()
toastMocks.error.mockReset()
})
afterEach(() => {
for (const { app, root } of mountedApps.splice(0)) {
app.unmount()
root.remove()
}
vi.unstubAllGlobals()
})
describe('ModelsTab batch delete', () => {
it('keeps delete selected hidden until a model is checked', async () => {
const { root } = mountTab()
await settle()
expect(root.querySelector('[data-testid="models-tab-select-all"]')).toBeTruthy()
expect(root.querySelector('[data-testid="models-tab-row-checkbox-model-1"]')).toBeTruthy()
expect(root.querySelector('[data-testid="models-tab-row-checkbox-model-2"]')).toBeTruthy()
expect(root.querySelector('[data-testid="models-tab-delete-selected"]')).toBeNull()
})
it('selects all, shows a partial state, and deletes after confirmation', async () => {
const { root, onRefresh } = mountTab()
await settle()
const selectAll = root.querySelector('[data-testid="models-tab-select-all"]') as HTMLInputElement
selectAll.click()
await settle()
expect(selectAll.checked).toBe(true)
expect(selectAll.indeterminate).toBe(false)
expect((root.querySelector('[data-testid="models-tab-row-checkbox-model-1"]') as HTMLInputElement).checked).toBe(true)
expect((root.querySelector('[data-testid="models-tab-row-checkbox-model-2"]') as HTMLInputElement).checked).toBe(true)
expect(root.textContent).toContain('已选 2 个')
;(root.querySelector('[data-testid="models-tab-row-checkbox-model-2"]') as HTMLInputElement).click()
await settle()
expect(selectAll.checked).toBe(false)
expect(selectAll.indeterminate).toBe(true)
expect(root.textContent).toContain('已选 1 个')
const deleteButton = root.querySelector('[data-testid="models-tab-delete-selected"]') as HTMLButtonElement
expect(deleteButton.textContent).toContain('删除选中')
deleteButton.click()
await settle()
expect(confirmMocks.confirmDanger).toHaveBeenCalledWith(
'确定删除选中的 1 个模型吗?\n\n此操作不可撤销。',
'批量删除模型',
)
expect(modelMocks.deleteModel).toHaveBeenCalledTimes(1)
expect(modelMocks.deleteModel).toHaveBeenCalledWith('provider-1', 'model-1')
expect(toastMocks.success).toHaveBeenCalledWith('成功删除 1 个模型')
expect(onRefresh).toHaveBeenCalledTimes(1)
expect(root.querySelector('[data-testid="models-tab-delete-selected"]')).toBeNull()
expect(selectAll.checked).toBe(false)
expect(selectAll.indeterminate).toBe(false)
})
it('does not delete when confirmation is cancelled', async () => {
confirmMocks.confirmDanger.mockResolvedValue(false)
const { root, onRefresh } = mountTab()
await settle()
;(root.querySelector('[data-testid="models-tab-row-checkbox-model-1"]') as HTMLInputElement).click()
await settle()
;(root.querySelector('[data-testid="models-tab-delete-selected"]') as HTMLButtonElement).click()
await settle()
expect(modelMocks.deleteModel).not.toHaveBeenCalled()
expect(onRefresh).not.toHaveBeenCalled()
expect(root.querySelector('[data-testid="models-tab-delete-selected"]')).toBeTruthy()
})
it('drops stale selection when the current provider list refreshes', async () => {
const { root, state } = mountTab()
await settle()
const selectAll = root.querySelector('[data-testid="models-tab-select-all"]') as HTMLInputElement
selectAll.click()
await settle()
expect(root.textContent).toContain('已选 2 个')
state.models = [sampleModels[1]]
await settle()
expect(root.textContent).toContain('已选 1 个')
expect(root.querySelector('[data-testid="models-tab-row-checkbox-model-1"]')).toBeNull()
expect((root.querySelector('[data-testid="models-tab-row-checkbox-model-2"]') as HTMLInputElement).checked).toBe(true)
})
it('clears selection when switching providers', async () => {
const { root, state } = mountTab()
await settle()
;(root.querySelector('[data-testid="models-tab-select-all"]') as HTMLInputElement).click()
await settle()
expect(root.textContent).toContain('已选 2 个')
state.provider = createProvider('provider-2')
await settle()
expect(root.querySelector('[data-testid="models-tab-delete-selected"]')).toBeNull()
expect((root.querySelector('[data-testid="models-tab-select-all"]') as HTMLInputElement).checked).toBe(false)
})
})
@@ -50,6 +50,10 @@ describe('translation coverage', () => {
['1 维度', '1 dimension'],
['14天0时', '14d 0h'],
['5天 0:00:00', '5d 0:00:00'],
['确定删除选中的 3 个模型吗?\n\n此操作不可撤销。', 'Delete the selected 3 models?\n\nThis cannot be undone.'],
['确定删除选中的 1 个模型吗?\n\n此操作不可撤销。', 'Delete the selected 1 model?\n\nThis cannot be undone.'],
['成功删除 2 个模型', 'Deleted 2 models'],
['1 个模型删除失败', 'Failed to delete 1 model'],
])('translates %s as a complete message', (source, expected) => {
expect(translateLegacyText(source, 'en-US')).toBe(expected)
})
+3
View File
@@ -2978,6 +2978,9 @@ const legacyDynamicPatterns: Array<[RegExp, (match: RegExpMatchArray) => string]
[/^(.+) $/u, match => `${match[1]} users`],
[/^ (.+) $/u, match => `${match[1]} format errors`],
[/^ (.+) $/u, match => `${match[1]} more errors hidden`],
[/^ (\d+) \n\n$/u, match => `Delete the selected ${match[1]} ${match[1] === '1' ? 'model' : 'models'}?\n\nThis cannot be undone.`],
[/^ (\d+) $/u, match => `Deleted ${match[1]} ${match[1] === '1' ? 'model' : 'models'}`],
[/^(\d+) $/u, match => `Failed to delete ${match[1]} ${match[1] === '1' ? 'model' : 'models'}`],
[/^(.+)\n\n$/u, match => `Delete plan "${match[1]}"?\n\nPlans with existing orders or entitlements cannot be deleted. Disable it instead. This cannot be undone.`],
[/^ "(.+)" \n\n Google $/u, match => `Delete mapping "${match[1]}"?\n\nThis only deletes the mapping record and will not delete the actual file on Google.`],
[/^(.+)$/u, match => `Delete batch "${match[1]}"? This cannot be undone.`],
+5 -2
View File
@@ -2429,6 +2429,8 @@ function generateMockKeysForProvider(providerId: string, count: number = 2) {
})
}
const deletedProviderModelIds = new Set<string>()
// 为 provider 生成 models
function generateMockModelsForProvider(providerId: string) {
const provider = MOCK_PROVIDERS.find(p => p.id === providerId)
@@ -2607,7 +2609,7 @@ function generateMockModelsForProvider(providerId: string) {
)
}
return models
return models.filter(model => !deletedProviderModelIds.has(String(model.id)))
}
// ========== 注册动态路由 ==========
@@ -3355,9 +3357,10 @@ registerDynamicRoute('PATCH', '/api/admin/providers/:providerId/models/:modelId'
})
// 删除 Provider Model
registerDynamicRoute('DELETE', '/api/admin/providers/:providerId/models/:modelId', async (_config, _params) => {
registerDynamicRoute('DELETE', '/api/admin/providers/:providerId/models/:modelId', async (_config, params) => {
await delay()
requireAdmin()
deletedProviderModelIds.add(params.modelId)
return createMockResponse({ message: '删除成功(演示模式)' })
})