feat(providers): pin associated models to the top of the associate dialog

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.
This commit is contained in:
Kayphoon
2026-09-14 08:03:59 +00:00
parent dfe88e34e7
commit e4f89de90e
2 changed files with 117 additions and 7 deletions
@@ -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)"
>
<div
@@ -255,15 +256,29 @@ const existingGlobalModelIds = computed(() => {
)
})
// 过滤后的全局模型
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]
})
// 全局模型是否全选
@@ -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'])
})
})