feat: filter API key groups by provider

This commit is contained in:
hezhaoqian1
2026-09-15 11:16:17 +08:00
parent badfad8b72
commit 55a95d4c67
5 changed files with 245 additions and 5 deletions
+13
View File
@@ -116,6 +116,19 @@ export default {
nameLabel: 'Name',
namePlaceholder: 'My API Key',
groupLabel: 'Group',
providerLabel: 'Provider',
providers: {
anthropic: 'Anthropic',
openai: 'OpenAI',
domestic: 'Chinese AI',
other: 'Other'
},
providerHints: {
anthropic: 'Choose an available Anthropic / Claude group',
openai: 'Choose an available OpenAI / GPT group',
domestic: 'Includes DeepSeek, Kimi, Zhipu GLM and MiniMax',
other: 'Includes Gemini, Grok, Antigravity, OpenCode and mixed groups'
},
selectGroup: 'Select a group',
statusLabel: 'Status',
selectStatus: 'Select status',
+13
View File
@@ -116,6 +116,19 @@ export default {
nameLabel: '名称',
namePlaceholder: '我的 API 密钥',
groupLabel: '分组',
providerLabel: '厂商',
providers: {
anthropic: 'Anthropic',
openai: 'OpenAI',
domestic: '国产模型',
other: '其他'
},
providerHints: {
anthropic: '选择 Anthropic / Claude 的可用分组',
openai: '选择 OpenAI / GPT 的可用分组',
domestic: '包含 DeepSeek、Kimi、智谱 GLM、MiniMax',
other: '包含 Gemini、Grok、Antigravity、OpenCode 和混合分组'
},
selectGroup: '选择分组',
statusLabel: '状态',
selectStatus: '选择状态',
+32
View File
@@ -0,0 +1,32 @@
import type { GroupPlatform } from '@/types'
export type KeyGroupProvider = 'anthropic' | 'openai' | 'domestic' | 'other'
export const KEY_GROUP_PROVIDERS = ['anthropic', 'openai', 'domestic', 'other'] as const
// Classify by the configured upstream platform, never by a group's display name.
const PROVIDER_BY_PLATFORM: Record<GroupPlatform, KeyGroupProvider> = {
anthropic: 'anthropic',
openai: 'openai',
kimi: 'domestic',
zhipu: 'domestic',
deepseek: 'domestic',
minimax: 'domestic',
gemini: 'other',
grok: 'other',
antigravity: 'other',
composite: 'other',
opencode_go: 'other'
}
export function getKeyGroupProvider(platform: GroupPlatform): KeyGroupProvider {
return PROVIDER_BY_PLATFORM[platform] ?? 'other'
}
// Collections use representative provider marks rather than an invented brand logo.
export const KEY_GROUP_PROVIDER_ICONS: Record<KeyGroupProvider, GroupPlatform[]> = {
anthropic: ['anthropic'],
openai: ['openai'],
domestic: ['deepseek', 'kimi'],
other: ['gemini', 'grok']
}
+87 -3
View File
@@ -485,12 +485,64 @@
/>
</div>
<fieldset v-if="!showEditModal" data-tour="key-form-provider">
<legend class="input-label">{{ t('keys.providerLabel') }}</legend>
<div class="grid grid-cols-2 gap-2 sm:grid-cols-4">
<label
v-for="provider in createProviderOptions"
:key="provider.value"
class="relative min-w-0"
:class="provider.count === 0 ? 'cursor-not-allowed' : 'cursor-pointer'"
>
<input
type="radio"
name="key-provider"
:value="provider.value"
:checked="createProvider === provider.value"
:disabled="provider.count === 0"
class="peer sr-only"
@change="selectCreateProvider(provider.value)"
/>
<span
class="flex h-full flex-col items-center gap-2 rounded-xl border border-gray-200 bg-white px-2 py-3 text-center transition-colors peer-checked:border-primary-500 peer-checked:bg-primary-50/60 peer-checked:ring-1 peer-checked:ring-primary-500 peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-primary-500 peer-disabled:opacity-40 dark:border-dark-600 dark:bg-dark-800 dark:peer-checked:border-primary-500 dark:peer-checked:bg-primary-500/10"
:class="provider.count > 0 && 'hover:border-primary-300 dark:hover:border-primary-700'"
>
<span class="flex h-8 items-center justify-center gap-1.5" aria-hidden="true">
<span
v-for="platform in KEY_GROUP_PROVIDER_ICONS[provider.value]"
:key="platform"
class="flex h-8 w-8 items-center justify-center rounded-lg"
:class="platformBadgeLightClass(platform)"
>
<PlatformIcon :platform="platform" size="lg" />
</span>
</span>
<span class="text-sm font-semibold text-gray-800 dark:text-gray-100">{{ provider.label }}</span>
</span>
<span
v-if="createProvider === provider.value"
class="absolute right-1.5 top-1.5 flex h-4 w-4 items-center justify-center rounded-full bg-primary-500 text-white"
aria-hidden="true"
>
<Icon name="check" size="xs" :stroke-width="3" />
</span>
</label>
</div>
<p class="mt-2 text-xs leading-5 text-gray-500 dark:text-gray-400" aria-live="polite">
{{ groups.length === 0 ? t('common.noGroupsAvailable') : t(`keys.providerHints.${createProvider}`) }}
</p>
</fieldset>
<div>
<label class="input-label">{{ t('keys.groupLabel') }}</label>
<label class="input-label" for="key-form-group">{{ t('keys.groupLabel') }}</label>
<Select
:key="showEditModal ? 'edit' : createProvider"
id="key-form-group"
:aria-label="t('keys.groupLabel')"
v-model="formData.group_id"
:options="groupOptions"
:options="formGroupOptions"
:placeholder="t('keys.selectGroup')"
:empty-text="t('common.noGroupsAvailable')"
:searchable="true"
:search-placeholder="t('keys.searchGroup')"
data-tour="key-form-group"
@@ -1146,7 +1198,7 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted, type ComponentPublicInstance } from 'vue'
import { ref, reactive, computed, watch, onMounted, onUnmounted, type ComponentPublicInstance } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { useOnboardingStore } from '@/stores/onboarding'
@@ -1175,6 +1227,9 @@ import type { Column } from '@/components/common/types'
import type { BatchApiKeyUsageStats } from '@/api/usage'
import { formatDateTime } from '@/utils/format'
import { maskApiKey } from '@/utils/maskApiKey'
import PlatformIcon from '@/components/common/PlatformIcon.vue'
import { platformBadgeLightClass } from '@/utils/platformColors'
import { KEY_GROUP_PROVIDERS, KEY_GROUP_PROVIDER_ICONS, getKeyGroupProvider, type KeyGroupProvider } from '@/utils/keyGroupProviders'
import {
buildCcSwitchImportDeeplink,
type CcSwitchClientType
@@ -1470,6 +1525,35 @@ const groupOptions = computed(() =>
}))
)
const createProvider = ref<KeyGroupProvider>('anthropic')
const createProviderOptions = computed(() => KEY_GROUP_PROVIDERS.map((value) => ({
value,
label: t(`keys.providers.${value}`),
count: groups.value.filter((group) => getKeyGroupProvider(group.platform) === value).length
})))
const formGroupOptions = computed(() => showEditModal.value
? groupOptions.value
: groupOptions.value.filter((group) => getKeyGroupProvider(group.platform) === createProvider.value)
)
const selectCreateProvider = (provider: KeyGroupProvider) => {
if (createProvider.value === provider) return
createProvider.value = provider
formData.value.group_id = null
}
// Also handles groups arriving after the create dialog has already opened.
watch([showCreateModal, createProviderOptions], ([isOpen, providers], [wasOpen]) => {
if (!isOpen) return
if (!wasOpen || !providers.some((provider) => provider.value === createProvider.value && provider.count > 0)) {
selectCreateProvider(providers.find((provider) => provider.count > 0)?.value ?? 'anthropic')
}
if (!formGroupOptions.value.some((group) => group.value === formData.value.group_id)) {
formData.value.group_id = null
}
})
// Group dropdown search
const groupSearchQuery = ref('')
const filteredGroupOptions = computed(() => {
@@ -3,6 +3,7 @@ import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { nextTick } from 'vue'
import type { ApiKey } from '@/types'
import { keysAPI } from '@/api'
import KeysView from '../KeysView.vue'
const {
@@ -227,8 +228,9 @@ const mountView = async () => {
DataTable: DataTableStub,
Pagination: PaginationStub,
BaseDialog: {
props: ['show'],
template: '<div v-if="show"><slot /><slot name="footer" /></div>',
props: ['show', 'title'],
emits: ['close'],
template: '<div v-if="show" role="dialog"><button data-test="close-dialog" @click="$emit(\'close\')">Close</button><slot /><slot name="footer" /></div>',
},
ConfirmDialog: true,
EmptyState: true,
@@ -269,6 +271,7 @@ describe('user KeysView column settings', () => {
listKeys.mockReset()
updateKey.mockReset()
vi.mocked(keysAPI.create).mockReset()
getPublicSettings.mockReset()
getDashboardApiKeysUsage.mockReset()
getAvailableGroups.mockReset()
@@ -543,4 +546,99 @@ describe('user KeysView column settings', () => {
expect.objectContaining({ signal: expect.any(AbortSignal) })
)
})
describe('create provider selection', () => {
const platforms = ['anthropic', 'openai', 'kimi', 'zhipu', 'deepseek', 'minimax', 'gemini', 'grok', 'antigravity', 'composite', 'opencode_go']
const availableGroups = platforms.map((platform, index) => ({
id: index + 1,
// Deliberately ambiguous names: classification must follow the platform.
name: `Shared group ${index + 1}`,
platform,
rate_multiplier: 1,
subscription_type: 'standard',
}))
const groupSelect = (wrapper: VueWrapper) => wrapper.findComponent('[data-tour="key-form-group"]')
const optionIds = (wrapper: VueWrapper) => groupSelect(wrapper).props('options').map((option: { value: number }) => option.value)
const chooseProvider = (wrapper: VueWrapper, value: string) => wrapper.get(`input[name="key-provider"][value="${value}"]`).setValue()
const openCreate = async () => {
const wrapper = await mountView()
await wrapper.get('[data-tour="keys-create-btn"]').trigger('click')
return wrapper
}
beforeEach(() => {
getAvailableGroups.mockResolvedValue(availableGroups)
})
it('classifies all configured platforms and retains the complete table filter', async () => {
const wrapper = await openCreate()
expect(wrapper.findAll('input[name="key-provider"]')).toHaveLength(4)
expect(optionIds(wrapper)).toEqual([1])
await chooseProvider(wrapper, 'openai')
expect(optionIds(wrapper)).toEqual([2])
await chooseProvider(wrapper, 'domestic')
expect(optionIds(wrapper)).toEqual([3, 4, 5, 6])
await chooseProvider(wrapper, 'other')
expect(optionIds(wrapper)).toEqual([7, 8, 9, 10, 11])
expect(wrapper.findAllComponents({ name: 'Select' })[0].props('options')).toHaveLength(13)
})
it('clears the previous group on provider change and submits only the newly selected group', async () => {
const wrapper = await openCreate()
await wrapper.get('[data-tour="key-form-name"]').setValue('My key')
await groupSelect(wrapper).vm.$emit('update:modelValue', 1)
await chooseProvider(wrapper, 'domestic')
expect(groupSelect(wrapper).props('modelValue')).toBeNull()
await wrapper.get('#key-form').trigger('submit')
expect(keysAPI.create).not.toHaveBeenCalled()
expect(showError).toHaveBeenCalledWith('keys.groupRequired')
await groupSelect(wrapper).vm.$emit('update:modelValue', 5)
vi.mocked(keysAPI.create).mockResolvedValue({ ...createApiKey(), group_id: 5 })
await wrapper.get('#key-form').trigger('submit')
await flushPromises()
expect(keysAPI.create).toHaveBeenCalledOnce()
expect(vi.mocked(keysAPI.create).mock.calls[0].slice(0, 2)).toEqual(['My key', 5])
})
it('defaults to a provider with available groups and disables empty categories', async () => {
getAvailableGroups.mockResolvedValue([availableGroups[5]])
const wrapper = await openCreate()
expect(wrapper.get<HTMLInputElement>('input[value="domestic"]').element.checked).toBe(true)
expect(wrapper.get<HTMLInputElement>('input[value="anthropic"]').element.disabled).toBe(true)
expect(optionIds(wrapper)).toEqual([6])
})
it('shows the empty state when no groups are available', async () => {
getAvailableGroups.mockResolvedValue([])
const wrapper = await openCreate()
expect(wrapper.get('[data-tour="key-form-provider"]').text()).toContain('common.noGroupsAvailable')
expect(optionIds(wrapper)).toEqual([])
expect(wrapper.findAll<HTMLInputElement>('input[name="key-provider"]').every((input) => input.element.disabled)).toBe(true)
})
it('selects an available provider when groups arrive after opening', async () => {
let resolveGroups!: (value: typeof availableGroups) => void
getAvailableGroups.mockReturnValue(new Promise((resolve) => { resolveGroups = resolve }))
const wrapper = await openCreate()
resolveGroups([availableGroups[1]])
await flushPromises()
expect(wrapper.get<HTMLInputElement>('input[value="openai"]').element.checked).toBe(true)
expect(optionIds(wrapper)).toEqual([2])
})
it('resets provider and group when reopening create, and preserves edit options', async () => {
const wrapper = await openCreate()
await chooseProvider(wrapper, 'domestic')
await groupSelect(wrapper).vm.$emit('update:modelValue', 5)
await wrapper.get('[data-test="close-dialog"]').trigger('click')
await wrapper.get('[data-tour="keys-create-btn"]').trigger('click')
expect(optionIds(wrapper)).toEqual([1])
expect(groupSelect(wrapper).props('modelValue')).toBeNull()
await wrapper.get('[data-test="close-dialog"]').trigger('click')
await getButtonByText(wrapper, 'common.edit').trigger('click')
expect(wrapper.find('[data-tour="key-form-provider"]').exists()).toBe(false)
expect(optionIds(wrapper)).toHaveLength(11)
})
})
})