diff --git a/frontend/src/features/providers/components/BatchAssignModelsDialog.vue b/frontend/src/features/providers/components/BatchAssignModelsDialog.vue index 19a6b1093..2c38f7cbf 100644 --- a/frontend/src/features/providers/components/BatchAssignModelsDialog.vue +++ b/frontend/src/features/providers/components/BatchAssignModelsDialog.vue @@ -98,6 +98,7 @@ v-for="model in filteredGlobalModels" :key="model.id" class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-muted cursor-pointer" + :data-testid="`batch-assign-model-${model.id}`" @click="toggleGlobalModelSelection(model.id)" >
{ ) }) -// 过滤后的全局模型 +function globalModelMatchesQuery(model: GlobalModelResponse, query: string): boolean { + if (!query) return true + return model.name.toLowerCase().includes(query) || model.display_name.toLowerCase().includes(query) +} + +function compareGlobalModelsByName(left: GlobalModelResponse, right: GlobalModelResponse): number { + const nameA = (left.display_name || left.name || '').toLowerCase() + const nameB = (right.display_name || right.name || '').toLowerCase() + return nameA.localeCompare(nameB) +} + +// 过滤后的全局模型:当前已勾选/已关联的排在可见结果顶部,便于取消关联 const filteredGlobalModels = computed(() => { const query = searchQuery.value.toLowerCase().trim() - return allGlobalModels.value.filter(m => { - if (query && !m.name.toLowerCase().includes(query) && !m.display_name.toLowerCase().includes(query)) { - return false - } - return true - }) + const selectedIds = selectedGlobalModelIds.value + const matched = allGlobalModels.value.filter(model => globalModelMatchesQuery(model, query)) + const pinned = matched + .filter(model => selectedIds.has(model.id)) + .sort(compareGlobalModelsByName) + const rest = matched + .filter(model => !selectedIds.has(model.id)) + .sort(compareGlobalModelsByName) + return [...pinned, ...rest] }) // 全局模型是否全选 diff --git a/frontend/src/features/providers/components/__tests__/BatchAssignModelsDialog.loading.spec.ts b/frontend/src/features/providers/components/__tests__/BatchAssignModelsDialog.loading.spec.ts index 619c29d71..63d4663bc 100644 --- a/frontend/src/features/providers/components/__tests__/BatchAssignModelsDialog.loading.spec.ts +++ b/frontend/src/features/providers/components/__tests__/BatchAssignModelsDialog.loading.spec.ts @@ -84,6 +84,36 @@ afterEach(() => { } }) +function createGlobalModel(id: string, name: string, displayName = name) { + return { + id, + name, + display_name: displayName, + is_active: true, + default_tiered_pricing: { tiers: [] }, + created_at: '2026-01-01T00:00:00Z', + } +} + +function createProviderModel(id: string, globalModelId: string) { + return { + id, + provider_id: 'provider-1', + global_model_id: globalModelId, + provider_model_name: globalModelId, + is_active: true, + is_available: true, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + } +} + +function visibleModelIds(root: HTMLElement): string[] { + return Array.from(root.querySelectorAll('[data-testid^="batch-assign-model-"]')) + .map(node => node.getAttribute('data-testid')?.replace('batch-assign-model-', '') ?? '') + .filter(Boolean) +} + describe('BatchAssignModelsDialog loading', () => { it('loads model choices when lazily mounted in the open state', async () => { const root = document.createElement('div') @@ -107,4 +137,69 @@ describe('BatchAssignModelsDialog loading', () => { expect(endpointMocks.getProviderModels).toHaveBeenCalledWith('provider-1') expect(endpointMocks.getProviderKeys).toHaveBeenCalledWith('provider-1') }) + + it('pins already associated models to the top of the list', async () => { + globalModelMocks.getGlobalModels.mockResolvedValue({ + models: [ + createGlobalModel('gm-zeta', 'zeta-model', 'Zeta'), + createGlobalModel('gm-alpha', 'alpha-model', 'Alpha'), + createGlobalModel('gm-mu', 'mu-model', 'Mu'), + ], + total: 3, + }) + endpointMocks.getProviderModels.mockResolvedValue([ + createProviderModel('pm-mu', 'gm-mu'), + ]) + + const root = document.createElement('div') + document.body.appendChild(root) + const app = createApp(defineComponent({ + setup() { + return () => h(BatchAssignModelsDialog, { + open: true, + providerId: 'provider-1', + }) + }, + })) + app.mount(root) + mountedApps.push({ app, root }) + await settle() + + expect(visibleModelIds(root)).toEqual(['gm-mu', 'gm-alpha', 'gm-zeta']) + }) + + it('keeps selected matches pinned above other search results', async () => { + globalModelMocks.getGlobalModels.mockResolvedValue({ + models: [ + createGlobalModel('gm-beta', 'beta-flash', 'Beta Flash'), + createGlobalModel('gm-alpha', 'alpha-flash', 'Alpha Flash'), + createGlobalModel('gm-other', 'other-model', 'Other'), + ], + total: 3, + }) + endpointMocks.getProviderModels.mockResolvedValue([ + createProviderModel('pm-beta', 'gm-beta'), + ]) + + const root = document.createElement('div') + document.body.appendChild(root) + const app = createApp(defineComponent({ + setup() { + return () => h(BatchAssignModelsDialog, { + open: true, + providerId: 'provider-1', + }) + }, + })) + app.mount(root) + mountedApps.push({ app, root }) + await settle() + + const search = root.querySelector('input') as HTMLInputElement + search.value = 'flash' + search.dispatchEvent(new Event('input', { bubbles: true })) + await settle() + + expect(visibleModelIds(root)).toEqual(['gm-beta', 'gm-alpha']) + }) })