From d8ae153ae99e55617aa5c8e6549dd2906478e253 Mon Sep 17 00:00:00 2001
From: yan9651688 <174289968+yan9651688@users.noreply.github.com>
Date: Tue, 28 Jul 2026 09:23:52 +0800
Subject: [PATCH] feat(accounts): make model IDs easy to copy
Administrators often need exact model identifiers while editing account whitelists. Add a dedicated copy action without changing model selection or upstream sync behavior.
Constraint: Keep the contribution frontend-only and avoid model routing or persistence changes
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep copy and selection as separate actions
Tested: focused Vitest, account component regression tests, frontend typecheck, lint, and production build
Not-tested: Authenticated browser screenshot
Related: Wei-Shaw/sub2api#2151
---
.../account/ModelWhitelistSelector.vue | 58 ++++++++----
.../__tests__/ModelWhitelistSelector.spec.ts | 89 +++++++++++++++++++
2 files changed, 129 insertions(+), 18 deletions(-)
create mode 100644 frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts
diff --git a/frontend/src/components/account/ModelWhitelistSelector.vue b/frontend/src/components/account/ModelWhitelistSelector.vue
index 584345b3b4..d8cec05cb9 100644
--- a/frontend/src/components/account/ModelWhitelistSelector.vue
+++ b/frontend/src/components/account/ModelWhitelistSelector.vue
@@ -47,28 +47,44 @@
/>
-
+
+
+
+
+
{{ model.value }}
+
+
+
{{ t('admin.accounts.noMatchingModels') }}
@@ -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
diff --git a/frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts b/frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts
new file mode 100644
index 0000000000..51f18611cc
--- /dev/null
+++ b/frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts
@@ -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('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, 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()
+ })
+})