feat(ccswitch): 添加一键导入和用量查询

This commit is contained in:
Entropy.Xu
2026-05-29 21:39:45 +08:00
parent 0d76a8e478
commit 7ed4b84654
23 changed files with 1720 additions and 48 deletions
+463 -3
View File
@@ -194,6 +194,16 @@
<!-- 操作按钮 -->
<TableCell class="py-4">
<div class="flex justify-center gap-1">
<Button
:data-testid="`ccswitch-open-${apiKey.id}`"
variant="ghost"
size="icon"
class="h-8 w-8"
title="导入到 CC Switch"
@click="openCcSwitchImportDialog(apiKey)"
>
<Download class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
@@ -285,6 +295,16 @@
</Badge>
</div>
<div class="flex items-center gap-0.5 flex-shrink-0">
<Button
:data-testid="`ccswitch-open-mobile-${apiKey.id}`"
variant="ghost"
size="icon"
class="h-7 w-7"
title="导入到 CC Switch"
@click="openCcSwitchImportDialog(apiKey)"
>
<Download class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
@@ -593,6 +613,16 @@
</div>
<template #footer>
<Button
data-testid="ccswitch-open-created-key"
variant="outline"
class="h-10 px-5 gap-2"
:disabled="!createdApiKey || !newKeyValue"
@click="openCcSwitchImportDialogForCreatedKey"
>
<Download class="h-4 w-4" />
导入 CC Switch
</Button>
<Button
class="h-10 px-5"
@click="closeCreatedKeyDialog"
@@ -602,6 +632,154 @@
</template>
</Dialog>
<!-- 导入 CC Switch 对话框 -->
<Dialog
v-model="showCcSwitchDialog"
size="lg"
>
<template #header>
<div class="border-b border-border px-6 py-4">
<div class="flex items-center gap-3">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10 flex-shrink-0">
<Download class="h-5 w-5 text-primary" />
</div>
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-foreground leading-tight">
导入到 CC Switch
</h3>
<p class="text-xs text-muted-foreground truncate">
当前密钥:{{ selectedCcSwitchApiKey?.name || '未选择' }}
</p>
</div>
</div>
</div>
</template>
<div class="space-y-5">
<div class="rounded-lg border border-border/60 bg-muted/30 p-3 text-xs text-muted-foreground">
选择目标客户端和模型 ID。点击导入后浏览器会请求打开 CC Switch,本页面不会展示或保存包含 API Key 的链接。
</div>
<div class="space-y-2">
<Label class="text-sm font-semibold">目标客户端</Label>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
<Button
v-for="option in ccSwitchTargetOptions"
:key="option.value"
:data-testid="`ccswitch-target-${option.value}`"
:variant="ccSwitchTargetApp === option.value ? 'default' : 'outline'"
class="justify-start h-auto py-3"
@click="selectCcSwitchTarget(option.value)"
>
{{ option.label }}
</Button>
</div>
</div>
<div class="space-y-2">
<Label
for="ccswitch-provider-name"
class="text-sm font-semibold"
>站点名称</Label>
<Input
id="ccswitch-provider-name"
:model-value="ccSwitchProviderName"
class="h-11 border-border/60"
autocomplete="off"
data-testid="ccswitch-provider-name"
@update:model-value="updateCcSwitchProviderName"
/>
</div>
<div class="space-y-3">
<Label class="text-sm font-semibold">
{{ ccSwitchTargetApp === 'claude' ? '模型 ID 选择' : '模型 ID' }}
</Label>
<div class="space-y-3">
<div
v-for="field in ccSwitchModelFields"
:key="field.key"
class="space-y-1.5"
>
<Label
:for="`ccswitch-model-${field.key}`"
class="text-xs font-medium text-muted-foreground"
>
{{ field.label }}
</Label>
<Select
v-model="ccSwitchModelIds[field.key]"
:disabled="!ccSwitchHasModelOptions"
>
<SelectTrigger
:id="`ccswitch-model-${field.key}`"
:data-testid="`ccswitch-model-select-${field.key}`"
class="h-11 rounded-2xl border-border/60 bg-card/80 font-mono text-xs"
>
<SelectValue placeholder="选择模型 ID" />
</SelectTrigger>
<SelectContent
class="max-w-[min(26rem,calc(100vw-4rem))] max-h-[22rem]"
search-placeholder="搜索模型 ID..."
:search-threshold="4"
>
<SelectItem
v-for="model in ccSwitchModelOptions"
:key="model"
:value="model"
:text-value="model"
class="font-mono"
>
{{ model }}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<p class="text-xs text-muted-foreground">
{{ ccSwitchModelHelpText }}
</p>
<p
v-if="!ccSwitchPreparing && !ccSwitchHasModelOptions"
class="text-xs text-destructive"
>
暂无可用模型,请联系管理员配置可用模型后再导入。
</p>
</div>
<div
v-if="ccSwitchPreparing"
class="text-xs text-muted-foreground"
>
正在加载导入配置...
</div>
</div>
<template #footer>
<Button
variant="outline"
class="h-10 px-5"
@click="showCcSwitchDialog = false"
>
关闭
</Button>
<Button
data-testid="ccswitch-confirm"
class="h-10 px-5 shadow-lg shadow-primary/20"
:disabled="ccSwitchConfirmDisabled"
@click="confirmCcSwitchImport"
>
<Loader2
v-if="ccSwitchLoading"
class="animate-spin h-4 w-4 mr-2"
/>
{{ ccSwitchLoading ? '准备中...' : '导入到 CC Switch' }}
</Button>
</template>
</Dialog>
<!-- 一键安装并配置 CLI 对话框 -->
<Dialog
v-model="showInstallDialog"
@@ -734,7 +912,7 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue'
import { ref, onMounted, onBeforeUnmount, computed, watch, reactive } from 'vue'
import { meApi, type ApiKey, type InstallSessionTargetSystem, type InstallTargetCli, type ApiKeyInstallSession } from '@/api/me'
import Card from '@/components/ui/card.vue'
import Button from '@/components/ui/button.vue'
@@ -742,7 +920,15 @@ import Input from '@/components/ui/input.vue'
import Label from '@/components/ui/label.vue'
import Badge from '@/components/ui/badge.vue'
import Switch from '@/components/ui/switch.vue'
import { Dialog, Pagination } from '@/components/ui'
import {
Dialog,
Pagination,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui'
import { LoadingState, AlertDialog, EmptyState } from '@/components/common'
import {
Table,
@@ -753,7 +939,7 @@ import {
TableRow
} from '@/components/ui'
import RefreshButton from '@/components/ui/refresh-button.vue'
import { Plus, Key, Copy, Trash2, Loader2, Activity, CheckCircle, Power, SquarePen, Terminal } from 'lucide-vue-next'
import { Plus, Key, Copy, Trash2, Loader2, Activity, CheckCircle, Power, SquarePen, Terminal, Download } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
import { log } from '@/utils/logger'
import { parseApiError } from '@/utils/errorParser'
@@ -765,6 +951,13 @@ import {
mergeChatPiiRedactionFeatureSettings,
readChatPiiRedactionFeatureSettings,
} from '@/utils/featureSettings'
import {
CC_SWITCH_TARGET_OPTIONS,
buildCcSwitchProviderImportUrl,
defaultCcSwitchProviderName,
type CcSwitchModelIds,
type CcSwitchTargetApp,
} from '@/features/api-keys/utils/ccswitchImport'
const { success, error: showError } = useToast()
@@ -780,6 +973,14 @@ const installSystemOptions: Array<{ value: InstallSessionTargetSystem; label: st
{ value: 'windows', label: 'Windows' }
]
const ccSwitchTargetOptions = CC_SWITCH_TARGET_OPTIONS
type CcSwitchModelFieldKey = keyof Required<CcSwitchModelIds>
interface CcSwitchModelField {
key: CcSwitchModelFieldKey
label: string
}
const apiKeys = ref<ApiKey[]>([])
const loading = ref(false)
const creating = ref(false)
@@ -798,6 +999,7 @@ const showCreateDialog = ref(false)
const showKeyDialog = ref(false)
const showDeleteDialog = ref(false)
const showInstallDialog = ref(false)
const showCcSwitchDialog = ref(false)
const newKeyName = ref('')
const newKeyRateLimit = ref<number | undefined>(undefined)
@@ -807,6 +1009,7 @@ const keyRedactionMode = ref<'inherit' | 'custom'>('inherit')
const newKeyRedactionEnabled = ref(false)
const newKeyRedactionInjectNotice = ref(true)
const newKeyValue = ref('')
const createdApiKey = ref<ApiKey | null>(null)
const keyToDelete = ref<ApiKey | null>(null)
const editingApiKey = ref<ApiKey | null>(null)
const selectedInstallApiKey = ref<ApiKey | null>(null)
@@ -817,6 +1020,22 @@ const installSession = ref<ApiKeyInstallSession | null>(null)
const installLoading = ref(false)
const installCopied = ref(false)
let installCopiedResetTimer: ReturnType<typeof setTimeout> | null = null
const selectedCcSwitchApiKey = ref<ApiKey | null>(null)
const ccSwitchPlainApiKey = ref('')
const ccSwitchTargetApp = ref<CcSwitchTargetApp>('claude')
const ccSwitchProviderName = ref('')
const ccSwitchProviderNameDirty = ref(false)
const ccSwitchSiteName = ref('Aether')
const ccSwitchModelIds = reactive<Record<CcSwitchModelFieldKey, string>>({
default: '',
haiku: '',
sonnet: '',
opus: '',
})
const ccSwitchBaseUrl = ref('')
const ccSwitchAvailableModels = ref<string[]>([])
const ccSwitchPreparing = ref(false)
const ccSwitchLoading = ref(false)
const installCommand = computed(() => {
if (!installSession.value) return ''
@@ -832,6 +1051,42 @@ const installCommandHint = computed(() => {
return 'macOS / Linux 请在 sh 兼容终端中执行。install code 使用后立即失效,如需再次执行请重新生成。'
})
const ccSwitchModelOptions = computed(() => ccSwitchAvailableModels.value)
const ccSwitchHasModelOptions = computed(() => ccSwitchModelOptions.value.length > 0)
const ccSwitchConfirmDisabled = computed(() =>
ccSwitchLoading.value || ccSwitchPreparing.value || !ccSwitchHasModelOptions.value,
)
const ccSwitchModelFields = computed<CcSwitchModelField[]>(() => {
if (ccSwitchTargetApp.value === 'claude') {
return [
{
key: 'haiku',
label: 'Haiku 模型 ID',
},
{
key: 'sonnet',
label: 'Sonnet 模型 ID',
},
{
key: 'opus',
label: 'Opus 模型 ID',
},
]
}
return [
{
key: 'default',
label: '默认模型 ID',
},
]
})
const ccSwitchModelHelpText = computed(() =>
ccSwitchTargetApp.value === 'claude'
? 'Claude Code 会分别写入 Haiku、Sonnet、Opus,Sonnet 同时作为默认模型;模型多时可在下拉中搜索并滚动选择。'
: '从 Aether 可用模型中选择,模型多时可在下拉中搜索并滚动选择。',
)
onMounted(() => {
installSystem.value = detectCurrentSystem()
loadApiKeys()
@@ -900,6 +1155,7 @@ function openEditApiKeyDialog(apiKey: ApiKey) {
function openCreateApiKeyDialog() {
editingApiKey.value = null
createdApiKey.value = null
newKeyName.value = ''
newKeyRateLimit.value = undefined
newKeyConcurrentLimit.value = undefined
@@ -968,10 +1224,210 @@ async function copyInstallCommand() {
}, 2000)
}
function uniqueModelNames(models: Array<{ name?: string | null; display_name?: string | null }>): string[] {
const names = new Set<string>()
for (const model of models) {
const name = String(model.name || '').trim()
if (name) names.add(name)
}
return Array.from(names)
}
function findRecommendedModel(
models: string[],
predicate: (model: string) => boolean,
): string | undefined {
return models.find(model => predicate(model.toLowerCase()))
}
function recommendedDefaultCcSwitchModel(targetApp: CcSwitchTargetApp, models: string[]): string {
const findModel = (predicate: (model: string) => boolean) =>
findRecommendedModel(models, predicate)
if (targetApp === 'claude') {
return findModel(model => model.includes('claude') && model.includes('sonnet'))
|| findModel(model => model.includes('claude'))
|| findModel(model => model.startsWith('gpt') || model.includes('gpt-'))
|| models[0]
|| ''
}
if (targetApp === 'gemini') {
return findModel(model => model.includes('gemini')) || models[0] || ''
}
return findModel(model => model.startsWith('gpt') || model.includes('gpt-'))
|| models[0]
|| ''
}
function recommendedCcSwitchModelIds(targetApp: CcSwitchTargetApp, models: string[]): Record<CcSwitchModelFieldKey, string> {
const defaultModel = recommendedDefaultCcSwitchModel(targetApp, models)
if (targetApp !== 'claude') {
return {
default: defaultModel,
haiku: '',
sonnet: '',
opus: '',
}
}
const gptFallback = findRecommendedModel(
models,
model => model.startsWith('gpt') || model.includes('gpt-'),
) || defaultModel
const sonnet = findRecommendedModel(
models,
model => model.includes('sonnet'),
) || findRecommendedModel(
models,
model => model.includes('claude'),
) || gptFallback
return {
default: sonnet,
haiku: findRecommendedModel(models, model => model.includes('haiku')) || gptFallback,
sonnet,
opus: findRecommendedModel(models, model => model.includes('opus')) || sonnet,
}
}
function applyRecommendedCcSwitchModelIds(targetApp: CcSwitchTargetApp) {
const recommended = recommendedCcSwitchModelIds(targetApp, ccSwitchAvailableModels.value)
for (const field of ccSwitchModelFields.value) {
ccSwitchModelIds[field.key] = recommended[field.key]
}
}
function updateCcSwitchProviderName(value: string) {
ccSwitchProviderName.value = value
ccSwitchProviderNameDirty.value = true
}
function selectedCcSwitchModelIds(): CcSwitchModelIds {
if (ccSwitchTargetApp.value === 'claude') {
return {
default: ccSwitchModelIds.sonnet.trim(),
haiku: ccSwitchModelIds.haiku.trim(),
sonnet: ccSwitchModelIds.sonnet.trim(),
opus: ccSwitchModelIds.opus.trim(),
}
}
return {
default: ccSwitchModelIds.default.trim(),
}
}
async function prepareCcSwitchDialog() {
ccSwitchPreparing.value = true
try {
const [clientConfig, modelsResponse] = await Promise.all([
meApi.getClientConfig(),
meApi.getAvailableModels({ limit: 1000 }),
])
ccSwitchBaseUrl.value = clientConfig.base_url
ccSwitchSiteName.value = clientConfig.site_name?.trim() || 'Aether'
if (!ccSwitchProviderNameDirty.value) {
ccSwitchProviderName.value = defaultCcSwitchProviderName(ccSwitchSiteName.value)
}
ccSwitchAvailableModels.value = uniqueModelNames(modelsResponse.models || [])
applyRecommendedCcSwitchModelIds(ccSwitchTargetApp.value)
} catch (error) {
log.error('加载 CC Switch 导入配置失败:', error)
showError(parseApiError(error, '加载 CC Switch 导入配置失败'))
} finally {
ccSwitchPreparing.value = false
}
}
async function openCcSwitchImportDialog(apiKey: ApiKey, plainApiKey = '') {
selectedCcSwitchApiKey.value = apiKey
ccSwitchPlainApiKey.value = plainApiKey
ccSwitchTargetApp.value = 'claude'
ccSwitchProviderNameDirty.value = false
ccSwitchProviderName.value = defaultCcSwitchProviderName(ccSwitchSiteName.value)
ccSwitchBaseUrl.value = ''
ccSwitchAvailableModels.value = []
for (const key of Object.keys(ccSwitchModelIds) as CcSwitchModelFieldKey[]) {
ccSwitchModelIds[key] = ''
}
showCcSwitchDialog.value = true
await prepareCcSwitchDialog()
}
async function openCcSwitchImportDialogForCreatedKey() {
if (!createdApiKey.value || !newKeyValue.value) return
pendingFirstInstallApiKey.value = null
showKeyDialog.value = false
await openCcSwitchImportDialog(createdApiKey.value, newKeyValue.value)
}
function selectCcSwitchTarget(value: CcSwitchTargetApp) {
ccSwitchTargetApp.value = value
applyRecommendedCcSwitchModelIds(value)
}
function isMissingFullApiKeyError(message: string): boolean {
return message.includes('没有存储完整密钥信息') || message.includes('缺少完整密钥')
}
async function confirmCcSwitchImport() {
if (!selectedCcSwitchApiKey.value) return
if (!ccSwitchHasModelOptions.value) {
showError('暂无可用模型,请联系管理员配置可用模型后再导入')
return
}
const missingModelField = ccSwitchModelFields.value.find(field => !ccSwitchModelIds[field.key].trim())
if (missingModelField) {
showError(`请填写${missingModelField.label}`)
return
}
ccSwitchLoading.value = true
try {
const apiKey = ccSwitchPlainApiKey.value
|| (await meApi.getFullApiKey(selectedCcSwitchApiKey.value.id)).key
const baseUrl = ccSwitchBaseUrl.value || (await meApi.getClientConfig()).base_url
const importUrl = buildCcSwitchProviderImportUrl({
targetApp: ccSwitchTargetApp.value,
baseUrl,
apiKey,
apiKeyName: selectedCcSwitchApiKey.value.name,
siteName: ccSwitchSiteName.value,
modelIds: selectedCcSwitchModelIds(),
providerName: ccSwitchProviderName.value,
})
if (importUrl.length > 8000) {
showError('CC Switch 导入链接过长,请减少模型配置后重试')
return
}
window.location.href = importUrl
success('如果浏览器询问是否打开 CC Switch,请选择允许')
showCcSwitchDialog.value = false
} catch (error) {
log.error('生成 CC Switch 导入链接失败:', error)
const message = parseApiError(error, '生成 CC Switch 导入链接失败')
showError(
isMissingFullApiKeyError(message)
? '该密钥缺少完整密钥信息,请重新创建 API Key'
: message,
)
} finally {
ccSwitchLoading.value = false
}
}
function closeCreatedKeyDialog() {
showKeyDialog.value = false
const pending = pendingFirstInstallApiKey.value
pendingFirstInstallApiKey.value = null
createdApiKey.value = null
if (pending) {
void openInstallDialog(pending)
}
@@ -980,6 +1436,9 @@ function closeCreatedKeyDialog() {
function closeApiKeyDialog() {
showCreateDialog.value = false
editingApiKey.value = null
if (!showKeyDialog.value) {
createdApiKey.value = null
}
newKeyName.value = ''
newKeyRateLimit.value = undefined
newKeyConcurrentLimit.value = undefined
@@ -1029,6 +1488,7 @@ async function saveApiKey() {
: {}),
})
newKeyValue.value = newKey.key || ''
createdApiKey.value = newKey
if (isCreatingFirstApiKey) {
pendingFirstInstallApiKey.value = newKey
}
@@ -0,0 +1,227 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, nextTick, type App } from 'vue'
import MyApiKeys from '../MyApiKeys.vue'
const toastMock = vi.hoisted(() => ({
success: vi.fn(),
error: vi.fn(),
}))
const meApiMock = vi.hoisted(() => ({
getApiKeys: vi.fn(),
createApiKey: vi.fn(),
getFullApiKey: vi.fn(),
getClientConfig: vi.fn(),
getAvailableModels: vi.fn(),
createApiKeyInstallSession: vi.fn(),
updateApiKey: vi.fn(),
deleteApiKey: vi.fn(),
toggleApiKey: vi.fn(),
}))
vi.mock('@/api/me', () => ({
meApi: meApiMock,
}))
vi.mock('@/composables/useToast', () => ({
useToast: () => toastMock,
}))
vi.mock('@/components/common', async () => {
const { defineComponent, h } = await import('vue')
return {
LoadingState: defineComponent({
props: { message: String },
setup: props => () => h('div', props.message || 'loading'),
}),
EmptyState: defineComponent({
props: { title: String, description: String, icon: [Object, Function] },
setup: (props, { slots }) => () => h('div', [
h('div', props.title || ''),
h('div', props.description || ''),
slots.actions?.(),
]),
}),
AlertDialog: defineComponent({
emits: ['confirm', 'cancel'],
setup: () => () => null,
}),
}
})
vi.mock('@/utils/logger', () => ({
log: {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
},
}))
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
function apiKey(overrides: Record<string, unknown> = {}) {
return {
id: 'user-key-1',
name: 'primary',
key_display: 'sk-user...live',
is_active: true,
is_locked: false,
created_at: '2026-05-29T00:00:00+00:00',
total_requests: 0,
total_cost_usd: 0,
rate_limit: 0,
concurrent_limit: 0,
ip_rules: null,
...overrides,
}
}
async function flushPromises() {
await nextTick()
await new Promise(resolve => setTimeout(resolve, 0))
await nextTick()
}
async function mountMyApiKeys() {
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(MyApiKeys)
app.mount(root)
mountedApps.push({ app, root })
await flushPromises()
return root
}
beforeEach(() => {
vi.clearAllMocks()
meApiMock.getClientConfig.mockResolvedValue({
base_url: 'https://aether.example.com',
site_name: 'Aether Local',
})
meApiMock.getAvailableModels.mockResolvedValue({
models: [
{ id: 'gm-1', name: 'claude-haiku-4', display_name: 'Claude Haiku 4', is_active: true },
{ id: 'gm-2', name: 'claude-sonnet-4', display_name: 'Claude Sonnet 4', is_active: true },
{ id: 'gm-3', name: 'claude-opus-4', display_name: 'Claude Opus 4', is_active: true },
{ id: 'gm-4', name: 'gpt-5', display_name: 'GPT 5', is_active: true },
],
total: 4,
})
meApiMock.createApiKeyInstallSession.mockResolvedValue({
install_code: 'install-code',
expires_at_unix_secs: 1,
expires_in_seconds: 900,
target_cli: 'claude_code',
target_cli_label: 'Claude Code',
target_system: 'linux',
target_system_label: 'Linux',
unix_command: 'curl install',
powershell_command: 'irm install',
})
})
afterEach(() => {
for (const { app, root } of mountedApps.splice(0)) {
app.unmount()
root.remove()
}
document.body.innerHTML = ''
})
describe('MyApiKeys CC Switch import', () => {
it('opens the import dialog for an existing key without fetching the full key immediately', async () => {
meApiMock.getApiKeys.mockResolvedValue([apiKey()])
await mountMyApiKeys()
document.querySelector<HTMLButtonElement>('[data-testid="ccswitch-open-user-key-1"]')?.click()
await flushPromises()
expect(meApiMock.getFullApiKey).not.toHaveBeenCalled()
expect(document.body.textContent).toContain('导入到 CC Switch')
expect(document.querySelector<HTMLInputElement>('[data-testid="ccswitch-provider-name"]')?.value).toBe('Aether Local')
expect(document.querySelector<HTMLElement>('[data-testid="ccswitch-model-select-haiku"]')?.textContent).toContain('claude-haiku-4')
expect(document.querySelector<HTMLElement>('[data-testid="ccswitch-model-select-sonnet"]')?.textContent).toContain('claude-sonnet-4')
expect(document.querySelector<HTMLElement>('[data-testid="ccswitch-model-select-opus"]')?.textContent).toContain('claude-opus-4')
})
it('switches non-Claude targets to a single default model without changing the site provider name', async () => {
meApiMock.getApiKeys.mockResolvedValue([apiKey()])
await mountMyApiKeys()
document.querySelector<HTMLButtonElement>('[data-testid="ccswitch-open-user-key-1"]')?.click()
await flushPromises()
document.querySelector<HTMLButtonElement>('[data-testid="ccswitch-target-codex"]')?.click()
await flushPromises()
expect(document.querySelector<HTMLInputElement>('[data-testid="ccswitch-provider-name"]')?.value).toBe('Aether Local')
expect(document.querySelector<HTMLElement>('[data-testid="ccswitch-model-select-default"]')?.textContent).toContain('gpt-5')
expect(document.querySelector<HTMLElement>('[data-testid="ccswitch-model-select-haiku"]')).toBeNull()
expect(document.querySelector<HTMLElement>('[data-testid="ccswitch-model-select-sonnet"]')).toBeNull()
expect(document.querySelector<HTMLElement>('[data-testid="ccswitch-model-select-opus"]')).toBeNull()
})
it('shows a specific message when an existing key cannot return full key material', async () => {
meApiMock.getApiKeys.mockResolvedValue([apiKey()])
meApiMock.getFullApiKey.mockRejectedValue({
response: { data: { detail: '该密钥没有存储完整密钥信息' } },
})
await mountMyApiKeys()
document.querySelector<HTMLButtonElement>('[data-testid="ccswitch-open-user-key-1"]')?.click()
await flushPromises()
document.querySelector<HTMLButtonElement>('[data-testid="ccswitch-confirm"]')?.click()
await flushPromises()
expect(toastMock.error).toHaveBeenCalledWith('该密钥缺少完整密钥信息,请重新创建 API Key')
})
it('does not fall back to an unlisted model when no available models are returned', async () => {
meApiMock.getApiKeys.mockResolvedValue([apiKey()])
meApiMock.getAvailableModels.mockResolvedValueOnce({
models: [],
total: 0,
})
await mountMyApiKeys()
document.querySelector<HTMLButtonElement>('[data-testid="ccswitch-open-user-key-1"]')?.click()
await flushPromises()
const confirmButton = document.querySelector<HTMLButtonElement>('[data-testid="ccswitch-confirm"]')
expect(document.body.textContent).toContain('暂无可用模型,请联系管理员配置可用模型后再导入。')
expect(document.querySelector<HTMLElement>('[data-testid="ccswitch-model-select-haiku"]')?.textContent).not.toContain('gpt-5')
expect(confirmButton?.disabled).toBe(true)
confirmButton?.click()
await flushPromises()
expect(meApiMock.getFullApiKey).not.toHaveBeenCalled()
})
it('can open CC Switch import from the newly created key dialog without refetching the key', async () => {
const createdKey = apiKey({ id: 'created-key-1', name: 'new key', key: 'sk-created-live' })
meApiMock.getApiKeys.mockResolvedValueOnce([]).mockResolvedValue([createdKey])
meApiMock.createApiKey.mockResolvedValue(createdKey)
await mountMyApiKeys()
document.querySelector<HTMLButtonElement>('[title="创建新 API Key"]')?.click()
await flushPromises()
const nameInput = document.querySelector<HTMLInputElement>('#key-name')
nameInput!.value = 'new key'
nameInput!.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
Array.from(document.querySelectorAll<HTMLButtonElement>('button'))
.find(button => button.textContent?.trim() === '创建')
?.click()
await flushPromises()
document.querySelector<HTMLButtonElement>('[data-testid="ccswitch-open-created-key"]')?.click()
await flushPromises()
expect(meApiMock.getFullApiKey).not.toHaveBeenCalled()
expect(document.body.textContent).toContain('导入到 CC Switch')
})
})