Merge pull request #4980 from yan9651688/feat/model-id-copy

feat(accounts): add one-click model ID copy
This commit is contained in:
Wesley Liddick
2026-07-28 11:06:48 +08:00
committed by GitHub
2 changed files with 129 additions and 18 deletions
@@ -47,28 +47,44 @@
/>
</div>
<div class="max-h-52 overflow-auto">
<button
<div
v-for="model in filteredModels"
:key="model.value"
type="button"
@click="toggleModel(model.value)"
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-dark-600"
data-testid="model-option"
class="group flex items-center hover:bg-gray-100 dark:hover:bg-dark-600"
>
<span
:class="[
'flex h-4 w-4 shrink-0 items-center justify-center rounded border',
modelValue.includes(model.value)
? 'border-primary-500 bg-primary-500 text-white'
: 'border-gray-300 dark:border-dark-500'
]"
<button
type="button"
data-testid="select-model"
class="flex min-w-0 flex-1 items-center gap-2 px-3 py-2 text-left text-sm"
@click="toggleModel(model.value)"
>
<svg v-if="modelValue.includes(model.value)" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
</svg>
</span>
<ModelIcon :model="model.value" size="18px" />
<span class="truncate text-gray-900 dark:text-white">{{ model.value }}</span>
</button>
<span
:class="[
'flex h-4 w-4 shrink-0 items-center justify-center rounded border',
modelValue.includes(model.value)
? 'border-primary-500 bg-primary-500 text-white'
: 'border-gray-300 dark:border-dark-500'
]"
>
<svg v-if="modelValue.includes(model.value)" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
</svg>
</span>
<ModelIcon :model="model.value" size="18px" />
<span class="truncate text-gray-900 dark:text-white">{{ model.value }}</span>
</button>
<button
type="button"
data-testid="copy-model-id"
class="mr-2 rounded p-1.5 text-gray-400 opacity-70 transition-colors hover:bg-gray-200 hover:text-primary-600 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 group-hover:opacity-100 dark:text-gray-500 dark:hover:bg-dark-500 dark:hover:text-primary-400"
:title="`${t('common.copy')} ${model.value}`"
:aria-label="`${t('common.copy')} ${model.value}`"
@click="copyModelId(model.value)"
>
<Icon name="copy" size="sm" />
</button>
</div>
<div v-if="filteredModels.length === 0" class="px-3 py-4 text-center text-sm text-gray-500">
{{ t('admin.accounts.noMatchingModels') }}
</div>
@@ -134,6 +150,7 @@ import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { accountsAPI } from '@/api/admin/accounts'
import type { SyncUpstreamPreviewParams } from '@/api/admin/accounts'
import { useClipboard } from '@/composables/useClipboard'
import ModelIcon from '@/components/common/ModelIcon.vue'
import Icon from '@/components/icons/Icon.vue'
import { allModels, getModelsByPlatform } from '@/composables/useModelWhitelist'
@@ -158,6 +175,7 @@ const emit = defineEmits<{
}>()
const appStore = useAppStore()
const { copyToClipboard } = useClipboard()
const showDropdown = ref(false)
const searchQuery = ref('')
@@ -233,6 +251,10 @@ const toggleModel = (model: string) => {
}
}
const copyModelId = async (model: string) => {
await copyToClipboard(model)
}
const addCustom = () => {
const model = customModel.value.trim()
if (!model) return
@@ -0,0 +1,89 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
const copyToClipboard = vi.fn().mockResolvedValue(true)
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({
t: (key: string) => (key === 'common.copy' ? '复制' : key)
})
}
})
vi.mock('@/stores/app', () => ({
useAppStore: () => ({
showError: vi.fn(),
showSuccess: vi.fn(),
showInfo: vi.fn()
})
}))
vi.mock('@/composables/useClipboard', () => ({
useClipboard: () => ({
copyToClipboard
})
}))
import ModelWhitelistSelector from '../ModelWhitelistSelector.vue'
function mountSelector() {
return mount(ModelWhitelistSelector, {
props: {
modelValue: [],
platform: 'openai'
},
global: {
stubs: {
ModelIcon: true
}
}
})
}
function findModelRow(wrapper: ReturnType<typeof mountSelector>, modelId: string) {
const row = wrapper
.findAll('[data-testid="model-option"]')
.find(candidate => candidate.text().includes(modelId))
if (!row) {
throw new Error(`Model row not found: ${modelId}`)
}
return row
}
describe('ModelWhitelistSelector', () => {
beforeEach(() => {
copyToClipboard.mockClear()
})
it('copies a model ID without selecting the model', async () => {
const wrapper = mountSelector()
await wrapper.get('div.cursor-pointer').trigger('click')
const row = findModelRow(wrapper, 'gpt-5.6-sol')
const copyButton = row.get('[data-testid="copy-model-id"]')
expect(copyButton.attributes('aria-label')).toBe('复制 gpt-5.6-sol')
await copyButton.trigger('click')
await flushPromises()
expect(copyToClipboard).toHaveBeenCalledWith('gpt-5.6-sol')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
it('keeps the existing model selection behavior', async () => {
const wrapper = mountSelector()
await wrapper.get('div.cursor-pointer').trigger('click')
const row = findModelRow(wrapper, 'gpt-5.6-sol')
await row.get('[data-testid="select-model"]').trigger('click')
expect(wrapper.emitted('update:modelValue')).toEqual([[['gpt-5.6-sol']]])
expect(copyToClipboard).not.toHaveBeenCalled()
})
})