mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 17:08:33 +08:00
Merge pull request #4980 from yan9651688/feat/model-id-copy
feat(accounts): add one-click model ID copy
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user