From e4f89de90e30265f61a16734dc1aaf9d763d393b Mon Sep 17 00:00:00 2001
From: Kayphoon <109347466+Kayphoon@users.noreply.github.com>
Date: Mon, 14 Sep 2026 07:56:19 +0000
Subject: [PATCH] feat(providers): pin associated models to the top of the
associate dialog
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Show already checked / already associated models first in the 关联模型
list so they are easy to find and uncheck. Keep that order inside the
current search results.
---
.../components/BatchAssignModelsDialog.vue | 29 ++++--
.../BatchAssignModelsDialog.loading.spec.ts | 95 +++++++++++++++++++
2 files changed, 117 insertions(+), 7 deletions(-)
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'])
+ })
})