mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
feat: filter API key groups by provider
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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: '选择状态',
|
||||
|
||||
@@ -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']
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user