mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-05 00:47:48 +08:00
Merge remote-tracking branch 'entropy-xu/codex/ccswitch-import'
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user