mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-16 16:07:45 +08:00
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:
@@ -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))
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
+265
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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.`],
|
||||
|
||||
@@ -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: '删除成功(演示模式)' })
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user