|
|
<template> |
|
|
<Teleport to="body"> |
|
|
<div v-if="show" class="modal fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-4"> |
|
|
<div |
|
|
class="modal-content custom-scrollbar mx-auto max-h-[90vh] w-full max-w-2xl overflow-y-auto p-4 sm:p-6 md:p-8" |
|
|
> |
|
|
<div class="mb-4 flex items-center justify-between sm:mb-6"> |
|
|
<div class="flex items-center gap-2 sm:gap-3"> |
|
|
<div |
|
|
class="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-green-500 to-green-600 sm:h-10 sm:w-10 sm:rounded-xl" |
|
|
> |
|
|
<i class="fas fa-user-circle text-sm text-white sm:text-base" /> |
|
|
</div> |
|
|
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100 sm:text-xl"> |
|
|
{{ isEdit ? '编辑账户' : '添加账户' }} |
|
|
</h3> |
|
|
</div> |
|
|
<button |
|
|
class="p-1 text-gray-400 transition-colors hover:text-gray-600" |
|
|
@click="$emit('close')" |
|
|
> |
|
|
<i class="fas fa-times text-lg sm:text-xl" /> |
|
|
</button> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div |
|
|
v-if="!isEdit && (form.addType === 'oauth' || form.addType === 'setup-token')" |
|
|
class="mb-4 flex items-center justify-center sm:mb-8" |
|
|
> |
|
|
<div class="flex items-center space-x-2 sm:space-x-4"> |
|
|
<div class="flex items-center"> |
|
|
<div |
|
|
:class="[ |
|
|
'flex h-6 w-6 items-center justify-center rounded-full text-xs font-semibold sm:h-8 sm:w-8 sm:text-sm', |
|
|
oauthStep >= 1 ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-500' |
|
|
]" |
|
|
> |
|
|
1 |
|
|
</div> |
|
|
<span |
|
|
class="ml-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 sm:ml-2 sm:text-sm" |
|
|
>基本信息</span |
|
|
> |
|
|
</div> |
|
|
<div class="h-0.5 w-4 bg-gray-300 sm:w-8" /> |
|
|
<div class="flex items-center"> |
|
|
<div |
|
|
:class="[ |
|
|
'flex h-6 w-6 items-center justify-center rounded-full text-xs font-semibold sm:h-8 sm:w-8 sm:text-sm', |
|
|
oauthStep >= 2 ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-500' |
|
|
]" |
|
|
> |
|
|
2 |
|
|
</div> |
|
|
<span |
|
|
class="ml-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 sm:ml-2 sm:text-sm" |
|
|
>授权认证</span |
|
|
> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div v-if="oauthStep === 1 && !isEdit"> |
|
|
<div class="space-y-6"> |
|
|
<div v-if="!isEdit"> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" |
|
|
>选择平台</label |
|
|
> |
|
|
|
|
|
<div class="space-y-3"> |
|
|
|
|
|
<div class="grid grid-cols-2 gap-2 sm:grid-cols-4"> |
|
|
|
|
|
<div |
|
|
class="group relative cursor-pointer overflow-hidden rounded-lg border-2 transition-all duration-200" |
|
|
:class="[ |
|
|
platformGroup === 'claude' |
|
|
? 'border-indigo-500 bg-gradient-to-br from-indigo-50 to-purple-50 shadow-md dark:from-indigo-900/20 dark:to-purple-900/20' |
|
|
: 'border-gray-200 bg-white hover:border-indigo-300 hover:shadow dark:border-gray-700 dark:bg-gray-800 dark:hover:border-indigo-600' |
|
|
]" |
|
|
@click="selectPlatformGroup('claude')" |
|
|
> |
|
|
<div class="p-3"> |
|
|
<div class="flex items-center justify-between"> |
|
|
<div |
|
|
class="flex h-8 w-8 items-center justify-center rounded-md bg-gradient-to-br from-indigo-500 to-purple-600" |
|
|
> |
|
|
<i class="fas fa-brain text-sm text-white"></i> |
|
|
</div> |
|
|
<div |
|
|
v-if="platformGroup === 'claude'" |
|
|
class="flex h-5 w-5 items-center justify-center rounded-full bg-indigo-500" |
|
|
> |
|
|
<i class="fas fa-check text-xs text-white"></i> |
|
|
</div> |
|
|
</div> |
|
|
<h4 class="mt-2 text-sm font-semibold text-gray-900 dark:text-gray-100"> |
|
|
Claude |
|
|
</h4> |
|
|
<p class="text-xs text-gray-600 dark:text-gray-400">Anthropic</p> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div |
|
|
class="group relative cursor-pointer overflow-hidden rounded-lg border-2 transition-all duration-200" |
|
|
:class="[ |
|
|
platformGroup === 'openai' |
|
|
? 'border-emerald-500 bg-gradient-to-br from-emerald-50 to-teal-50 shadow-md dark:from-emerald-900/20 dark:to-teal-900/20' |
|
|
: 'border-gray-200 bg-white hover:border-emerald-300 hover:shadow dark:border-gray-700 dark:bg-gray-800 dark:hover:border-emerald-600' |
|
|
]" |
|
|
@click="selectPlatformGroup('openai')" |
|
|
> |
|
|
<div class="p-3"> |
|
|
<div class="flex items-center justify-between"> |
|
|
<div |
|
|
class="flex h-8 w-8 items-center justify-center rounded-md bg-gradient-to-br from-emerald-500 to-teal-600" |
|
|
> |
|
|
<svg |
|
|
class="h-5 w-5 text-white" |
|
|
fill="currentColor" |
|
|
viewBox="0 0 24 24" |
|
|
xmlns="http://www.w3.org/2000/svg" |
|
|
> |
|
|
<path |
|
|
d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.8956zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.4069-.6813zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z" |
|
|
/> |
|
|
</svg> |
|
|
</div> |
|
|
<div |
|
|
v-if="platformGroup === 'openai'" |
|
|
class="flex h-5 w-5 items-center justify-center rounded-full bg-emerald-500" |
|
|
> |
|
|
<i class="fas fa-check text-xs text-white"></i> |
|
|
</div> |
|
|
</div> |
|
|
<h4 class="mt-2 text-sm font-semibold text-gray-900 dark:text-gray-100"> |
|
|
OpenAI |
|
|
</h4> |
|
|
<p class="text-xs text-gray-600 dark:text-gray-400">GPT 系列</p> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div |
|
|
class="group relative cursor-pointer overflow-hidden rounded-lg border-2 transition-all duration-200" |
|
|
:class="[ |
|
|
platformGroup === 'gemini' |
|
|
? 'border-blue-500 bg-gradient-to-br from-blue-50 to-indigo-50 shadow-md dark:from-blue-900/20 dark:to-indigo-900/20' |
|
|
: 'border-gray-200 bg-white hover:border-blue-300 hover:shadow dark:border-gray-700 dark:bg-gray-800 dark:hover:border-blue-600' |
|
|
]" |
|
|
@click="selectPlatformGroup('gemini')" |
|
|
> |
|
|
<div class="p-3"> |
|
|
<div class="flex items-center justify-between"> |
|
|
<div |
|
|
class="flex h-8 w-8 items-center justify-center rounded-md bg-gradient-to-br from-blue-500 to-indigo-600" |
|
|
> |
|
|
<i class="fab fa-google text-sm text-white"></i> |
|
|
</div> |
|
|
<div |
|
|
v-if="platformGroup === 'gemini'" |
|
|
class="flex h-5 w-5 items-center justify-center rounded-full bg-blue-500" |
|
|
> |
|
|
<i class="fas fa-check text-xs text-white"></i> |
|
|
</div> |
|
|
</div> |
|
|
<h4 class="mt-2 text-sm font-semibold text-gray-900 dark:text-gray-100"> |
|
|
Gemini |
|
|
</h4> |
|
|
<p class="text-xs text-gray-600 dark:text-gray-400">Google AI</p> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div |
|
|
class="group relative cursor-pointer overflow-hidden rounded-lg border-2 transition-all duration-200" |
|
|
:class="[ |
|
|
platformGroup === 'droid' |
|
|
? 'border-rose-500 bg-gradient-to-br from-rose-50 to-orange-50 shadow-md dark:from-rose-900/20 dark:to-orange-900/20' |
|
|
: 'border-gray-200 bg-white hover:border-rose-300 hover:shadow dark:border-gray-700 dark:bg-gray-800 dark:hover:border-rose-600' |
|
|
]" |
|
|
@click="selectPlatformGroup('droid')" |
|
|
> |
|
|
<div class="p-3"> |
|
|
<div class="flex items-center justify-between"> |
|
|
<div |
|
|
class="flex h-8 w-8 items-center justify-center rounded-md bg-gradient-to-br from-rose-500 to-orange-500" |
|
|
> |
|
|
<i class="fas fa-robot text-sm text-white"></i> |
|
|
</div> |
|
|
<div |
|
|
v-if="platformGroup === 'droid'" |
|
|
class="flex h-5 w-5 items-center justify-center rounded-full bg-rose-500" |
|
|
> |
|
|
<i class="fas fa-check text-xs text-white"></i> |
|
|
</div> |
|
|
</div> |
|
|
<h4 class="mt-2 text-sm font-semibold text-gray-900 dark:text-gray-100"> |
|
|
Droid |
|
|
</h4> |
|
|
<p class="text-xs text-gray-600 dark:text-gray-400">Claude Droid</p> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div |
|
|
v-if="platformGroup" |
|
|
class="animate-fadeIn rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800/50" |
|
|
> |
|
|
<p class="mb-2 text-xs font-medium text-gray-700 dark:text-gray-300"> |
|
|
选择具体平台类型: |
|
|
</p> |
|
|
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3"> |
|
|
|
|
|
<template v-if="platformGroup === 'claude'"> |
|
|
<label |
|
|
class="group relative flex cursor-pointer items-center rounded-md border p-2 transition-all" |
|
|
:class="[ |
|
|
form.platform === 'claude' |
|
|
? 'border-indigo-500 bg-indigo-50 dark:border-indigo-400 dark:bg-indigo-900/30' |
|
|
: 'border-gray-300 bg-white hover:border-indigo-400 hover:bg-indigo-50/50 dark:border-gray-600 dark:bg-gray-700 dark:hover:border-indigo-500 dark:hover:bg-indigo-900/20' |
|
|
]" |
|
|
> |
|
|
<input |
|
|
v-model="form.platform" |
|
|
class="sr-only" |
|
|
type="radio" |
|
|
value="claude" |
|
|
/> |
|
|
<div class="flex items-center gap-2"> |
|
|
<i class="fas fa-brain text-sm text-indigo-600 dark:text-indigo-400"></i> |
|
|
<div> |
|
|
<span class="block text-xs font-medium text-gray-900 dark:text-gray-100" |
|
|
>Claude Code</span |
|
|
> |
|
|
<span class="text-xs text-gray-500 dark:text-gray-400">官方</span> |
|
|
</div> |
|
|
</div> |
|
|
<div |
|
|
v-if="form.platform === 'claude'" |
|
|
class="absolute right-1 top-1 flex h-4 w-4 items-center justify-center rounded-full bg-indigo-500" |
|
|
> |
|
|
<i class="fas fa-check text-xs text-white"></i> |
|
|
</div> |
|
|
</label> |
|
|
|
|
|
<label |
|
|
class="group relative flex cursor-pointer items-center rounded-md border p-2 transition-all" |
|
|
:class="[ |
|
|
form.platform === 'claude-console' |
|
|
? 'border-purple-500 bg-purple-50 dark:border-purple-400 dark:bg-purple-900/30' |
|
|
: 'border-gray-300 bg-white hover:border-purple-400 hover:bg-purple-50/50 dark:border-gray-600 dark:bg-gray-700 dark:hover:border-purple-500 dark:hover:bg-purple-900/20' |
|
|
]" |
|
|
> |
|
|
<input |
|
|
v-model="form.platform" |
|
|
class="sr-only" |
|
|
type="radio" |
|
|
value="claude-console" |
|
|
/> |
|
|
<div class="flex items-center gap-2"> |
|
|
<i |
|
|
class="fas fa-terminal text-sm text-purple-600 dark:text-purple-400" |
|
|
></i> |
|
|
<div> |
|
|
<span class="block text-xs font-medium text-gray-900 dark:text-gray-100" |
|
|
>Claude Console</span |
|
|
> |
|
|
<span class="text-xs text-gray-500 dark:text-gray-400">标准API</span> |
|
|
</div> |
|
|
</div> |
|
|
<div |
|
|
v-if="form.platform === 'claude-console'" |
|
|
class="absolute right-1 top-1 flex h-4 w-4 items-center justify-center rounded-full bg-purple-500" |
|
|
> |
|
|
<i class="fas fa-check text-xs text-white"></i> |
|
|
</div> |
|
|
</label> |
|
|
|
|
|
<label |
|
|
class="group relative flex cursor-pointer items-center rounded-md border p-2 transition-all" |
|
|
:class="[ |
|
|
form.platform === 'bedrock' |
|
|
? 'border-orange-500 bg-orange-50 dark:border-orange-400 dark:bg-orange-900/30' |
|
|
: 'border-gray-300 bg-white hover:border-orange-400 hover:bg-orange-50/50 dark:border-gray-600 dark:bg-gray-700 dark:hover:border-orange-500 dark:hover:bg-orange-900/20' |
|
|
]" |
|
|
> |
|
|
<input |
|
|
v-model="form.platform" |
|
|
class="sr-only" |
|
|
type="radio" |
|
|
value="bedrock" |
|
|
/> |
|
|
<div class="flex items-center gap-2"> |
|
|
<i class="fab fa-aws text-sm text-orange-600 dark:text-orange-400"></i> |
|
|
<div> |
|
|
<span class="block text-xs font-medium text-gray-900 dark:text-gray-100" |
|
|
>Bedrock</span |
|
|
> |
|
|
<span class="text-xs text-gray-500 dark:text-gray-400">AWS</span> |
|
|
</div> |
|
|
</div> |
|
|
<div |
|
|
v-if="form.platform === 'bedrock'" |
|
|
class="absolute right-1 top-1 flex h-4 w-4 items-center justify-center rounded-full bg-orange-500" |
|
|
> |
|
|
<i class="fas fa-check text-xs text-white"></i> |
|
|
</div> |
|
|
</label> |
|
|
|
|
|
<label |
|
|
class="group relative flex cursor-pointer items-center rounded-md border p-2 transition-all" |
|
|
:class="[ |
|
|
form.platform === 'ccr' |
|
|
? 'border-cyan-500 bg-cyan-50 dark:border-cyan-400 dark:bg-cyan-900/30' |
|
|
: 'border-gray-300 bg-white hover:border-cyan-400 hover:bg-cyan-50/50 dark:border-gray-600 dark:bg-gray-700 dark:hover:border-cyan-500 dark:hover:bg-cyan-900/20' |
|
|
]" |
|
|
> |
|
|
<input v-model="form.platform" class="sr-only" type="radio" value="ccr" /> |
|
|
<div class="flex items-center gap-2"> |
|
|
<i |
|
|
class="fas fa-code-branch text-sm text-cyan-600 dark:text-cyan-400" |
|
|
></i> |
|
|
<div> |
|
|
<span class="block text-xs font-medium text-gray-900 dark:text-gray-100" |
|
|
>CCR</span |
|
|
> |
|
|
<span class="text-xs text-gray-500 dark:text-gray-400" |
|
|
>Claude Code Router</span |
|
|
> |
|
|
</div> |
|
|
</div> |
|
|
<div |
|
|
v-if="form.platform === 'ccr'" |
|
|
class="absolute right-1 top-1 flex h-4 w-4 items-center justify-center rounded-full bg-cyan-500" |
|
|
> |
|
|
<i class="fas fa-check text-xs text-white"></i> |
|
|
</div> |
|
|
</label> |
|
|
</template> |
|
|
|
|
|
|
|
|
<template v-if="platformGroup === 'openai'"> |
|
|
<label |
|
|
class="group relative flex cursor-pointer items-center rounded-md border p-2 transition-all" |
|
|
:class="[ |
|
|
form.platform === 'openai' |
|
|
? 'border-emerald-500 bg-emerald-50 dark:border-emerald-400 dark:bg-emerald-900/30' |
|
|
: 'border-gray-300 bg-white hover:border-emerald-400 hover:bg-emerald-50/50 dark:border-gray-600 dark:bg-gray-700 dark:hover:border-emerald-500 dark:hover:bg-emerald-900/20' |
|
|
]" |
|
|
> |
|
|
<input |
|
|
v-model="form.platform" |
|
|
class="sr-only" |
|
|
type="radio" |
|
|
value="openai" |
|
|
/> |
|
|
<div class="flex items-center gap-2"> |
|
|
<i |
|
|
class="fas fa-robot text-sm text-emerald-600 dark:text-emerald-400" |
|
|
></i> |
|
|
<div> |
|
|
<span class="block text-xs font-medium text-gray-900 dark:text-gray-100" |
|
|
>Codex Cli</span |
|
|
> |
|
|
<span class="text-xs text-gray-500 dark:text-gray-400">官方</span> |
|
|
</div> |
|
|
</div> |
|
|
<div |
|
|
v-if="form.platform === 'openai'" |
|
|
class="absolute right-1 top-1 flex h-4 w-4 items-center justify-center rounded-full bg-emerald-500" |
|
|
> |
|
|
<i class="fas fa-check text-xs text-white"></i> |
|
|
</div> |
|
|
</label> |
|
|
|
|
|
<label |
|
|
class="group relative flex cursor-pointer items-center rounded-md border p-2 transition-all" |
|
|
:class="[ |
|
|
form.platform === 'openai-responses' |
|
|
? 'border-teal-500 bg-teal-50 dark:border-teal-400 dark:bg-teal-900/30' |
|
|
: 'border-gray-300 bg-white hover:border-teal-400 hover:bg-teal-50/50 dark:border-gray-600 dark:bg-gray-700 dark:hover:border-teal-500 dark:hover:bg-teal-900/20' |
|
|
]" |
|
|
> |
|
|
<input |
|
|
v-model="form.platform" |
|
|
class="sr-only" |
|
|
type="radio" |
|
|
value="openai-responses" |
|
|
/> |
|
|
<div class="flex items-center gap-2"> |
|
|
<i class="fas fa-server text-sm text-teal-600 dark:text-teal-400"></i> |
|
|
<div> |
|
|
<span class="block text-xs font-medium text-gray-900 dark:text-gray-100" |
|
|
>Responses</span |
|
|
> |
|
|
<span class="text-xs text-gray-500 dark:text-gray-400" |
|
|
>Openai-Responses</span |
|
|
> |
|
|
</div> |
|
|
</div> |
|
|
<div |
|
|
v-if="form.platform === 'openai-responses'" |
|
|
class="absolute right-1 top-1 flex h-4 w-4 items-center justify-center rounded-full bg-teal-500" |
|
|
> |
|
|
<i class="fas fa-check text-xs text-white"></i> |
|
|
</div> |
|
|
</label> |
|
|
|
|
|
<label |
|
|
class="group relative flex cursor-pointer items-center rounded-md border p-2 transition-all" |
|
|
:class="[ |
|
|
form.platform === 'azure_openai' |
|
|
? 'border-blue-500 bg-blue-50 dark:border-blue-400 dark:bg-blue-900/30' |
|
|
: 'border-gray-300 bg-white hover:border-blue-400 hover:bg-blue-50/50 dark:border-gray-600 dark:bg-gray-700 dark:hover:border-blue-500 dark:hover:bg-blue-900/20' |
|
|
]" |
|
|
> |
|
|
<input |
|
|
v-model="form.platform" |
|
|
class="sr-only" |
|
|
type="radio" |
|
|
value="azure_openai" |
|
|
/> |
|
|
<div class="flex items-center gap-2"> |
|
|
<i class="fab fa-microsoft text-sm text-blue-600 dark:text-blue-400"></i> |
|
|
<div> |
|
|
<span class="block text-xs font-medium text-gray-900 dark:text-gray-100" |
|
|
>Azure</span |
|
|
> |
|
|
<span class="text-xs text-gray-500 dark:text-gray-400" |
|
|
>Azure Openai</span |
|
|
> |
|
|
</div> |
|
|
</div> |
|
|
<div |
|
|
v-if="form.platform === 'azure_openai'" |
|
|
class="absolute right-1 top-1 flex h-4 w-4 items-center justify-center rounded-full bg-blue-500" |
|
|
> |
|
|
<i class="fas fa-check text-xs text-white"></i> |
|
|
</div> |
|
|
</label> |
|
|
</template> |
|
|
|
|
|
|
|
|
<template v-if="platformGroup === 'gemini'"> |
|
|
<label |
|
|
class="group relative flex cursor-pointer items-center rounded-md border p-2 transition-all" |
|
|
:class="[ |
|
|
form.platform === 'gemini' |
|
|
? 'border-blue-500 bg-blue-50 dark:border-blue-400 dark:bg-blue-900/30' |
|
|
: 'border-gray-300 bg-white hover:border-blue-400 hover:bg-blue-50/50 dark:border-gray-600 dark:bg-gray-700 dark:hover:border-blue-500 dark:hover:bg-blue-900/20' |
|
|
]" |
|
|
> |
|
|
<input |
|
|
v-model="form.platform" |
|
|
class="sr-only" |
|
|
type="radio" |
|
|
value="gemini" |
|
|
/> |
|
|
<div class="flex items-center gap-2"> |
|
|
<i class="fab fa-google text-sm text-blue-600 dark:text-blue-400"></i> |
|
|
<div> |
|
|
<span class="block text-xs font-medium text-gray-900 dark:text-gray-100" |
|
|
>Gemini Cli</span |
|
|
> |
|
|
<span class="text-xs text-gray-500 dark:text-gray-400">官方</span> |
|
|
</div> |
|
|
</div> |
|
|
<div |
|
|
v-if="form.platform === 'gemini'" |
|
|
class="absolute right-1 top-1 flex h-4 w-4 items-center justify-center rounded-full bg-blue-500" |
|
|
> |
|
|
<i class="fas fa-check text-xs text-white"></i> |
|
|
</div> |
|
|
</label> |
|
|
</template> |
|
|
|
|
|
|
|
|
<template v-if="platformGroup === 'droid'"> |
|
|
<label |
|
|
class="group relative flex cursor-pointer items-center rounded-md border p-2 transition-all" |
|
|
:class="[ |
|
|
form.platform === 'droid' |
|
|
? 'border-rose-500 bg-rose-50 dark:border-rose-400 dark:bg-rose-900/30' |
|
|
: 'border-gray-300 bg-white hover:border-rose-400 hover:bg-rose-50/50 dark:border-gray-600 dark:bg-gray-700 dark:hover:border-rose-500 dark:hover:bg-rose-900/20' |
|
|
]" |
|
|
> |
|
|
<input v-model="form.platform" class="sr-only" type="radio" value="droid" /> |
|
|
<div class="flex items-center gap-2"> |
|
|
<i class="fas fa-robot text-sm text-rose-600 dark:text-rose-400"></i> |
|
|
<div> |
|
|
<span class="block text-xs font-medium text-gray-900 dark:text-gray-100" |
|
|
>Droid 专属</span |
|
|
> |
|
|
<span class="text-xs text-gray-500 dark:text-gray-400">官方</span> |
|
|
</div> |
|
|
</div> |
|
|
<div |
|
|
v-if="form.platform === 'droid'" |
|
|
class="absolute right-1 top-1 flex h-4 w-4 items-center justify-center rounded-full bg-rose-500" |
|
|
> |
|
|
<i class="fas fa-check text-xs text-white"></i> |
|
|
</div> |
|
|
</label> |
|
|
</template> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div |
|
|
v-if=" |
|
|
!isEdit && |
|
|
form.platform !== 'claude-console' && |
|
|
form.platform !== 'ccr' && |
|
|
form.platform !== 'bedrock' && |
|
|
form.platform !== 'azure_openai' && |
|
|
form.platform !== 'openai-responses' |
|
|
" |
|
|
> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" |
|
|
>添加方式</label |
|
|
> |
|
|
<div class="flex flex-wrap gap-4"> |
|
|
<label class="flex cursor-pointer items-center"> |
|
|
<input |
|
|
v-model="form.addType" |
|
|
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700" |
|
|
type="radio" |
|
|
value="oauth" |
|
|
/> |
|
|
<span class="text-sm text-gray-700 dark:text-gray-300"> |
|
|
OAuth 授权<span v-if="form.platform === 'claude' || form.platform === 'openai'"> |
|
|
(用量可视化)</span |
|
|
> |
|
|
</span> |
|
|
</label> |
|
|
<label v-if="form.platform === 'claude'" class="flex cursor-pointer items-center"> |
|
|
<input |
|
|
v-model="form.addType" |
|
|
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700" |
|
|
type="radio" |
|
|
value="setup-token" |
|
|
/> |
|
|
<span class="text-sm text-gray-700 dark:text-gray-300">Setup Token (效期长)</span> |
|
|
</label> |
|
|
<label class="flex cursor-pointer items-center"> |
|
|
<input |
|
|
v-model="form.addType" |
|
|
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700" |
|
|
type="radio" |
|
|
value="manual" |
|
|
/> |
|
|
<span class="text-sm text-gray-700 dark:text-gray-300" |
|
|
>手动输入 Access Token</span |
|
|
> |
|
|
</label> |
|
|
<label v-if="form.platform === 'droid'" class="flex cursor-pointer items-center"> |
|
|
<input |
|
|
v-model="form.addType" |
|
|
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700" |
|
|
type="radio" |
|
|
value="apikey" |
|
|
/> |
|
|
<span class="text-sm text-gray-700 dark:text-gray-300" |
|
|
>使用 API Key (支持多个)</span |
|
|
> |
|
|
</label> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" |
|
|
>账户名称</label |
|
|
> |
|
|
<input |
|
|
v-model="form.name" |
|
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" |
|
|
:class="{ 'border-red-500': errors.name }" |
|
|
placeholder="为账户设置一个易识别的名称" |
|
|
required |
|
|
type="text" |
|
|
/> |
|
|
<p v-if="errors.name" class="mt-1 text-xs text-red-500"> |
|
|
{{ errors.name }} |
|
|
</p> |
|
|
</div> |
|
|
|
|
|
<div> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" |
|
|
>描述 (可选)</label |
|
|
> |
|
|
<textarea |
|
|
v-model="form.description" |
|
|
class="form-input w-full resize-none border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" |
|
|
placeholder="账户用途说明..." |
|
|
rows="3" |
|
|
/> |
|
|
</div> |
|
|
|
|
|
<div> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" |
|
|
>账户类型</label |
|
|
> |
|
|
<div class="flex gap-4"> |
|
|
<label class="flex cursor-pointer items-center"> |
|
|
<input |
|
|
v-model="form.accountType" |
|
|
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700" |
|
|
type="radio" |
|
|
value="shared" |
|
|
/> |
|
|
<span class="text-sm text-gray-700 dark:text-gray-300">共享账户</span> |
|
|
</label> |
|
|
<label class="flex cursor-pointer items-center"> |
|
|
<input |
|
|
v-model="form.accountType" |
|
|
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700" |
|
|
type="radio" |
|
|
value="dedicated" |
|
|
/> |
|
|
<span class="text-sm text-gray-700 dark:text-gray-300">专属账户</span> |
|
|
</label> |
|
|
<label class="flex cursor-pointer items-center"> |
|
|
<input |
|
|
v-model="form.accountType" |
|
|
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700" |
|
|
type="radio" |
|
|
value="group" |
|
|
/> |
|
|
<span class="text-sm text-gray-700 dark:text-gray-300">分组调度</span> |
|
|
</label> |
|
|
</div> |
|
|
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400"> |
|
|
共享账户:供所有API Key使用;专属账户:仅供特定API |
|
|
Key使用;分组调度:加入分组供分组内调度 |
|
|
</p> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div v-if="!isEdit"> |
|
|
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300" |
|
|
>到期时间 (可选)</label |
|
|
> |
|
|
<div |
|
|
class="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800" |
|
|
> |
|
|
<select |
|
|
v-model="form.expireDuration" |
|
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200" |
|
|
@change="updateAccountExpireAt" |
|
|
> |
|
|
<option value="">永不过期</option> |
|
|
<option value="30d">30 天</option> |
|
|
<option value="90d">90 天</option> |
|
|
<option value="180d">180 天</option> |
|
|
<option value="365d">365 天</option> |
|
|
<option value="custom">自定义日期</option> |
|
|
</select> |
|
|
<div v-if="form.expireDuration === 'custom'" class="mt-3"> |
|
|
<input |
|
|
v-model="form.customExpireDate" |
|
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200" |
|
|
:min="minDateTime" |
|
|
type="datetime-local" |
|
|
@change="updateAccountCustomExpireAt" |
|
|
/> |
|
|
</div> |
|
|
<p v-if="form.expiresAt" class="mt-2 text-xs text-gray-500 dark:text-gray-400"> |
|
|
<i class="fas fa-calendar-alt mr-1" /> |
|
|
将于 {{ formatExpireDate(form.expiresAt) }} 过期 |
|
|
</p> |
|
|
<p v-else class="mt-2 text-xs text-gray-500 dark:text-gray-400"> |
|
|
<i class="fas fa-infinity mr-1" /> |
|
|
账户永不过期 |
|
|
</p> |
|
|
</div> |
|
|
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400"> |
|
|
设置 Claude Max/Pro 订阅的到期时间,到期后将停止调度此账户 |
|
|
</p> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div v-if="form.accountType === 'group'"> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" |
|
|
>选择分组 *</label |
|
|
> |
|
|
<div class="flex gap-2"> |
|
|
<div class="flex-1"> |
|
|
|
|
|
<div |
|
|
class="max-h-48 space-y-2 overflow-y-auto rounded-md border p-3 dark:border-gray-600 dark:bg-gray-700" |
|
|
> |
|
|
<div |
|
|
v-if="filteredGroups.length === 0" |
|
|
class="text-sm text-gray-500 dark:text-gray-400" |
|
|
> |
|
|
暂无可用分组 |
|
|
</div> |
|
|
<label |
|
|
v-for="group in filteredGroups" |
|
|
:key="group.id" |
|
|
class="flex cursor-pointer items-center gap-2 rounded-md p-2 hover:bg-gray-50 dark:hover:bg-gray-600" |
|
|
> |
|
|
<input |
|
|
v-model="form.groupIds" |
|
|
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700" |
|
|
type="checkbox" |
|
|
:value="group.id" |
|
|
/> |
|
|
<span class="text-sm text-gray-700 dark:text-gray-200"> |
|
|
{{ group.name }} ({{ group.memberCount || 0 }} 个成员) |
|
|
</span> |
|
|
</label> |
|
|
|
|
|
<div class="border-t pt-2 dark:border-gray-600"> |
|
|
<button |
|
|
class="flex items-center gap-2 text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300" |
|
|
type="button" |
|
|
@click="handleNewGroup" |
|
|
> |
|
|
<i class="fas fa-plus" /> |
|
|
新建分组 |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
<button |
|
|
class="rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600" |
|
|
type="button" |
|
|
@click="refreshGroups" |
|
|
> |
|
|
<i class="fas fa-sync-alt" :class="{ 'animate-spin': loadingGroups }" /> |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div v-if="form.platform === 'gemini'"> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" |
|
|
>项目 ID (可选)</label |
|
|
> |
|
|
<input |
|
|
v-model="form.projectId" |
|
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" |
|
|
placeholder="例如:verdant-wares-464411-k9" |
|
|
type="text" |
|
|
/> |
|
|
<div class="mt-2 rounded-lg border border-yellow-200 bg-yellow-50 p-3"> |
|
|
<div class="flex items-start gap-2"> |
|
|
<i class="fas fa-info-circle mt-0.5 text-yellow-600" /> |
|
|
<div class="text-xs text-yellow-700"> |
|
|
<p class="mb-1 font-medium">Google Cloud/Workspace 账号需要提供项目 ID</p> |
|
|
<p> |
|
|
某些 Google 账号(特别是绑定了 Google Cloud 的账号)会被识别为 Workspace |
|
|
账号,需要提供额外的项目 ID。 |
|
|
</p> |
|
|
<div class="mt-2 rounded border border-yellow-300 bg-white p-2"> |
|
|
<p class="mb-1 font-medium">如何获取项目 ID:</p> |
|
|
<ol class="ml-2 list-inside list-decimal space-y-1"> |
|
|
<li> |
|
|
访问 |
|
|
<a |
|
|
class="font-medium text-blue-600 hover:underline" |
|
|
href="https://console.cloud.google.com/welcome" |
|
|
target="_blank" |
|
|
>Google Cloud Console</a |
|
|
> |
|
|
</li> |
|
|
<li> |
|
|
复制<span class="font-semibold text-red-600">项目 ID(Project ID)</span |
|
|
>,通常是字符串格式 |
|
|
</li> |
|
|
<li class="text-red-600"> |
|
|
⚠️ 注意:要复制项目 ID(Project ID),不要复制项目编号(Project Number)! |
|
|
</li> |
|
|
</ol> |
|
|
</div> |
|
|
<p class="mt-2"> |
|
|
<strong>提示:</strong>如果您的账号是普通个人账号(未绑定 Google |
|
|
Cloud),请留空此字段。 |
|
|
</p> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div v-if="form.platform === 'bedrock' && !isEdit" class="space-y-4"> |
|
|
<div> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" |
|
|
>AWS 访问密钥 ID *</label |
|
|
> |
|
|
<input |
|
|
v-model="form.accessKeyId" |
|
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" |
|
|
:class="{ 'border-red-500': errors.accessKeyId }" |
|
|
placeholder="请输入 AWS Access Key ID" |
|
|
required |
|
|
type="text" |
|
|
/> |
|
|
<p v-if="errors.accessKeyId" class="mt-1 text-xs text-red-500"> |
|
|
{{ errors.accessKeyId }} |
|
|
</p> |
|
|
</div> |
|
|
|
|
|
<div> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" |
|
|
>AWS 秘密访问密钥 *</label |
|
|
> |
|
|
<input |
|
|
v-model="form.secretAccessKey" |
|
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" |
|
|
:class="{ 'border-red-500': errors.secretAccessKey }" |
|
|
placeholder="请输入 AWS Secret Access Key" |
|
|
required |
|
|
type="password" |
|
|
/> |
|
|
<p v-if="errors.secretAccessKey" class="mt-1 text-xs text-red-500"> |
|
|
{{ errors.secretAccessKey }} |
|
|
</p> |
|
|
</div> |
|
|
|
|
|
<div> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" |
|
|
>AWS 区域 *</label |
|
|
> |
|
|
<input |
|
|
v-model="form.region" |
|
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" |
|
|
:class="{ 'border-red-500': errors.region }" |
|
|
placeholder="例如:us-east-1" |
|
|
required |
|
|
type="text" |
|
|
/> |
|
|
<p v-if="errors.region" class="mt-1 text-xs text-red-500"> |
|
|
{{ errors.region }} |
|
|
</p> |
|
|
<div class="mt-2 rounded-lg border border-blue-200 bg-blue-50 p-3"> |
|
|
<div class="flex items-start gap-2"> |
|
|
<i class="fas fa-info-circle mt-0.5 text-blue-600" /> |
|
|
<div class="text-xs text-blue-700"> |
|
|
<p class="mb-1 font-medium">常用 AWS 区域参考:</p> |
|
|
<div class="grid grid-cols-2 gap-1 text-xs"> |
|
|
<span>• us-east-1 (美国东部)</span> |
|
|
<span>• us-west-2 (美国西部)</span> |
|
|
<span>• eu-west-1 (欧洲爱尔兰)</span> |
|
|
<span>• ap-southeast-1 (新加坡)</span> |
|
|
<span>• ap-northeast-1 (东京)</span> |
|
|
<span>• eu-central-1 (法兰克福)</span> |
|
|
</div> |
|
|
<p class="mt-2 text-blue-600">💡 请输入完整的区域代码,如 us-east-1</p> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" |
|
|
>会话令牌 (可选)</label |
|
|
> |
|
|
<input |
|
|
v-model="form.sessionToken" |
|
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" |
|
|
placeholder="如果使用临时凭证,请输入会话令牌" |
|
|
type="password" |
|
|
/> |
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> |
|
|
仅在使用临时 AWS 凭证时需要填写 |
|
|
</p> |
|
|
</div> |
|
|
|
|
|
<div> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" |
|
|
>默认主模型 (可选)</label |
|
|
> |
|
|
<input |
|
|
v-model="form.defaultModel" |
|
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" |
|
|
placeholder="例如:us.anthropic.claude-sonnet-4-20250514-v1:0" |
|
|
type="text" |
|
|
/> |
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> |
|
|
留空将使用系统默认模型。支持 inference profile ID 或 ARN |
|
|
</p> |
|
|
<div class="mt-2 rounded-lg border border-amber-200 bg-amber-50 p-3"> |
|
|
<div class="flex items-start gap-2"> |
|
|
<i class="fas fa-info-circle mt-0.5 text-amber-600" /> |
|
|
<div class="text-xs text-amber-700"> |
|
|
<p class="mb-1 font-medium">Bedrock 模型配置说明:</p> |
|
|
<ul class="list-inside list-disc space-y-1 text-xs"> |
|
|
<li>支持 Inference Profile ID(推荐)</li> |
|
|
<li>支持 Application Inference Profile ARN</li> |
|
|
<li>常用模型:us.anthropic.claude-sonnet-4-20250514-v1:0</li> |
|
|
<li>留空将使用系统配置的默认模型</li> |
|
|
</ul> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" |
|
|
>小快速模型 (可选)</label |
|
|
> |
|
|
<input |
|
|
v-model="form.smallFastModel" |
|
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" |
|
|
placeholder="例如:us.anthropic.claude-3-5-haiku-20241022-v1:0" |
|
|
type="text" |
|
|
/> |
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> |
|
|
用于快速响应的轻量级模型,留空将使用系统默认 |
|
|
</p> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div v-if="form.platform === 'azure_openai' && !isEdit" class="space-y-4"> |
|
|
<div> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" |
|
|
>Azure Endpoint *</label |
|
|
> |
|
|
<input |
|
|
v-model="form.azureEndpoint" |
|
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" |
|
|
:class="{ 'border-red-500': errors.azureEndpoint }" |
|
|
placeholder="https://your-resource.openai.azure.com" |
|
|
required |
|
|
type="url" |
|
|
/> |
|
|
<p v-if="errors.azureEndpoint" class="mt-1 text-xs text-red-500"> |
|
|
{{ errors.azureEndpoint }} |
|
|
</p> |
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> |
|
|
Azure OpenAI 资源的终结点 URL,格式:https://your-resource.openai.azure.com |
|
|
</p> |
|
|
</div> |
|
|
|
|
|
<div> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" |
|
|
>API 版本</label |
|
|
> |
|
|
<input |
|
|
v-model="form.apiVersion" |
|
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" |
|
|
placeholder="2024-02-01" |
|
|
type="text" |
|
|
/> |
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> |
|
|
Azure OpenAI API 版本,默认使用最新稳定版本 2024-02-01 |
|
|
</p> |
|
|
</div> |
|
|
|
|
|
<div> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" |
|
|
>部署名称 *</label |
|
|
> |
|
|
<input |
|
|
v-model="form.deploymentName" |
|
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" |
|
|
:class="{ 'border-red-500': errors.deploymentName }" |
|
|
placeholder="gpt-4" |
|
|
required |
|
|
type="text" |
|
|
/> |
|
|
<p v-if="errors.deploymentName" class="mt-1 text-xs text-red-500"> |
|
|
{{ errors.deploymentName }} |
|
|
</p> |
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> |
|
|
在 Azure OpenAI Studio 中创建的部署名称 |
|
|
</p> |
|
|
</div> |
|
|
|
|
|
<div> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" |
|
|
>API Key *</label |
|
|
> |
|
|
<input |
|
|
v-model="form.apiKey" |
|
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" |
|
|
:class="{ 'border-red-500': errors.apiKey }" |
|
|
placeholder="请输入 Azure OpenAI API Key" |
|
|
required |
|
|
type="password" |
|
|
/> |
|
|
<p v-if="errors.apiKey" class="mt-1 text-xs text-red-500"> |
|
|
{{ errors.apiKey }} |
|
|
</p> |
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> |
|
|
从 Azure 门户获取的 API 密钥 |
|
|
</p> |
|
|
</div> |
|
|
|
|
|
<div> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" |
|
|
>支持的模型</label |
|
|
> |
|
|
<div class="flex flex-wrap gap-2"> |
|
|
<label |
|
|
v-for="model in [ |
|
|
'gpt-4', |
|
|
'gpt-4-turbo', |
|
|
'gpt-4o', |
|
|
'gpt-4o-mini', |
|
|
'gpt-5', |
|
|
'gpt-5-mini', |
|
|
'gpt-35-turbo', |
|
|
'gpt-35-turbo-16k', |
|
|
'codex-mini' |
|
|
]" |
|
|
:key="model" |
|
|
class="flex cursor-pointer items-center" |
|
|
> |
|
|
<input |
|
|
v-model="form.supportedModels" |
|
|
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700" |
|
|
type="checkbox" |
|
|
:value="model" |
|
|
/> |
|
|
<span class="text-sm text-gray-700 dark:text-gray-300">{{ model }}</span> |
|
|
</label> |
|
|
</div> |
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> |
|
|
选择此部署支持的模型类型 |
|
|
</p> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div v-if="form.platform === 'bedrock' && !isEdit"> |
|
|
<div> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" |
|
|
>限流机制</label |
|
|
> |
|
|
<div class="mb-3"> |
|
|
<label class="inline-flex cursor-pointer items-center"> |
|
|
<input |
|
|
v-model="form.enableRateLimit" |
|
|
class="mr-2 rounded border-gray-300 text-blue-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-gray-600 dark:bg-gray-700" |
|
|
type="checkbox" |
|
|
/> |
|
|
<span class="text-sm text-gray-700 dark:text-gray-300">启用限流机制</span> |
|
|
</label> |
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> |
|
|
启用后,当账号返回429错误时将暂停调度一段时间 |
|
|
</p> |
|
|
</div> |
|
|
|
|
|
<div v-if="form.enableRateLimit"> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" |
|
|
>限流时间 (分钟)</label |
|
|
> |
|
|
<input |
|
|
v-model.number="form.rateLimitDuration" |
|
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" |
|
|
min="1" |
|
|
placeholder="默认60分钟" |
|
|
type="number" |
|
|
/> |
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> |
|
|
账号被限流后暂停调度的时间(分钟) |
|
|
</p> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div |
|
|
v-if="(form.platform === 'claude-console' || form.platform === 'ccr') && !isEdit" |
|
|
class="space-y-4" |
|
|
> |
|
|
<div> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" |
|
|
>API URL *</label |
|
|
> |
|
|
<input |
|
|
v-model="form.apiUrl" |
|
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" |
|
|
:class="{ 'border-red-500': errors.apiUrl }" |
|
|
placeholder="例如:https://api.example.com" |
|
|
required |
|
|
type="text" |
|
|
/> |
|
|
<p v-if="errors.apiUrl" class="mt-1 text-xs text-red-500"> |
|
|
{{ errors.apiUrl }} |
|
|
</p> |
|
|
</div> |
|
|
|
|
|
<div> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" |
|
|
>API Key *</label |
|
|
> |
|
|
<input |
|
|
v-model="form.apiKey" |
|
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" |
|
|
:class="{ 'border-red-500': errors.apiKey }" |
|
|
placeholder="请输入API Key" |
|
|
required |
|
|
type="password" |
|
|
/> |
|
|
<p v-if="errors.apiKey" class="mt-1 text-xs text-red-500"> |
|
|
{{ errors.apiKey }} |
|
|
</p> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="grid grid-cols-2 gap-4"> |
|
|
<div> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"> |
|
|
每日额度限制 ($) |
|
|
</label> |
|
|
<input |
|
|
v-model.number="form.dailyQuota" |
|
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200" |
|
|
min="0" |
|
|
placeholder="0 表示不限制" |
|
|
step="0.01" |
|
|
type="number" |
|
|
/> |
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> |
|
|
设置每日使用额度,0 表示不限制 |
|
|
</p> |
|
|
</div> |
|
|
|
|
|
<div> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"> |
|
|
额度重置时间 |
|
|
</label> |
|
|
<input |
|
|
v-model="form.quotaResetTime" |
|
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200" |
|
|
placeholder="00:00" |
|
|
type="time" |
|
|
/> |
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> |
|
|
每日自动重置额度的时间 |
|
|
</p> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" |
|
|
>模型限制 (可选)</label |
|
|
> |
|
|
|
|
|
|
|
|
<div class="mb-4 flex gap-2"> |
|
|
<button |
|
|
class="flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all" |
|
|
:class=" |
|
|
modelRestrictionMode === 'whitelist' |
|
|
? 'bg-blue-500 text-white shadow-md' |
|
|
: 'border border-gray-300 text-gray-600 hover:border-blue-300 dark:border-gray-600 dark:text-gray-400 dark:hover:border-blue-500' |
|
|
" |
|
|
type="button" |
|
|
@click="modelRestrictionMode = 'whitelist'" |
|
|
> |
|
|
<i class="fas fa-check-circle mr-2" /> |
|
|
模型白名单 |
|
|
</button> |
|
|
<button |
|
|
class="flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all" |
|
|
:class=" |
|
|
modelRestrictionMode === 'mapping' |
|
|
? 'bg-purple-500 text-white shadow-md' |
|
|
: 'border border-gray-300 text-gray-600 hover:border-purple-300 dark:border-gray-600 dark:text-gray-400 dark:hover:border-purple-500' |
|
|
" |
|
|
type="button" |
|
|
@click="modelRestrictionMode = 'mapping'" |
|
|
> |
|
|
<i class="fas fa-random mr-2" /> |
|
|
模型映射 |
|
|
</button> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div v-if="modelRestrictionMode === 'whitelist'"> |
|
|
<div class="mb-3 rounded-lg bg-blue-50 p-3 dark:bg-blue-900/30"> |
|
|
<p class="text-xs text-blue-700 dark:text-blue-400"> |
|
|
<i class="fas fa-info-circle mr-1" /> |
|
|
选择允许使用此账户的模型。留空表示支持所有模型。 |
|
|
</p> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="mb-3 grid grid-cols-2 gap-2"> |
|
|
<label |
|
|
v-for="model in commonModels" |
|
|
:key="model.value" |
|
|
class="flex cursor-pointer items-center rounded-lg border p-3 transition-all hover:bg-gray-50 dark:border-gray-600 dark:hover:bg-gray-700" |
|
|
:class=" |
|
|
allowedModels.includes(model.value) |
|
|
? 'border-blue-500 bg-blue-50 dark:border-blue-400 dark:bg-blue-900/30' |
|
|
: 'border-gray-300' |
|
|
" |
|
|
> |
|
|
<input |
|
|
v-model="allowedModels" |
|
|
class="mr-2 text-blue-600 focus:ring-blue-500" |
|
|
type="checkbox" |
|
|
:value="model.value" |
|
|
/> |
|
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ |
|
|
model.label |
|
|
}}</span> |
|
|
</label> |
|
|
</div> |
|
|
|
|
|
<p class="text-xs text-gray-500 dark:text-gray-400"> |
|
|
已选择 {{ allowedModels.length }} 个模型 |
|
|
<span v-if="allowedModels.length === 0">(支持所有模型)</span> |
|
|
</p> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div v-else> |
|
|
<div class="mb-3 rounded-lg bg-purple-50 p-3 dark:bg-purple-900/30"> |
|
|
<p class="text-xs text-purple-700 dark:text-purple-400"> |
|
|
<i class="fas fa-info-circle mr-1" /> |
|
|
配置模型映射关系。左侧是客户端请求的模型,右侧是实际发送给API的模型。 |
|
|
</p> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="mb-3 space-y-2"> |
|
|
<div |
|
|
v-for="(mapping, index) in modelMappings" |
|
|
:key="index" |
|
|
class="flex items-center gap-2" |
|
|
> |
|
|
<input |
|
|
v-model="mapping.from" |
|
|
class="form-input flex-1 border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" |
|
|
placeholder="原始模型名称" |
|
|
type="text" |
|
|
/> |
|
|
<i class="fas fa-arrow-right text-gray-400 dark:text-gray-500" /> |
|
|
<input |
|
|
v-model="mapping.to" |
|
|
class="form-input flex-1 border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" |
|
|
placeholder="映射后的模型名称" |
|
|
type="text" |
|
|
/> |
|
|
<button |
|
|
class="rounded-lg p-2 text-red-500 transition-colors hover:bg-red-50 dark:hover:bg-red-900/20" |
|
|
type="button" |
|
|
@click="removeModelMapping(index)" |
|
|
> |
|
|
<i class="fas fa-trash" /> |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<button |
|
|
class="w-full rounded-lg border-2 border-dashed border-gray-300 px-4 py-2 text-gray-600 transition-colors hover:border-gray-400 hover:text-gray-700 dark:border-gray-600 dark:text-gray-400 dark:hover:border-gray-500 dark:hover:text-gray-300" |
|
|
type="button" |
|
|
@click="addModelMapping" |
|
|
> |
|
|
<i class="fas fa-plus mr-2" /> |
|
|
添加模型映射 |
|
|
</button> |
|
|
|
|
|
|
|
|
<div class="mt-3 flex flex-wrap gap-2"> |
|
|
<button |
|
|
class="rounded-lg bg-blue-100 px-3 py-1 text-xs text-blue-700 transition-colors hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400 dark:hover:bg-blue-900/50" |
|
|
type="button" |
|
|
@click=" |
|
|
addPresetMapping('claude-sonnet-4-20250514', 'claude-sonnet-4-20250514') |
|
|
" |
|
|
> |
|
|
+ Sonnet 4 |
|
|
</button> |
|
|
<button |
|
|
class="rounded-lg bg-indigo-100 px-3 py-1 text-xs text-indigo-700 transition-colors hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400 dark:hover:bg-indigo-900/50" |
|
|
type="button" |
|
|
@click=" |
|
|
addPresetMapping('claude-sonnet-4-5-20250929', 'claude-sonnet-4-5-20250929') |
|
|
" |
|
|
> |
|
|
+ Sonnet 4.5 |
|
|
</button> |
|
|
<button |
|
|
class="rounded-lg bg-purple-100 px-3 py-1 text-xs text-purple-700 transition-colors hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400 dark:hover:bg-purple-900/50" |
|
|
type="button" |
|
|
@click=" |
|
|
addPresetMapping('claude-opus-4-1-20250805', 'claude-opus-4-1-20250805') |
|
|
" |
|
|
> |
|
|
+ Opus 4.1 |
|
|
</button> |
|
|
<button |
|
|
class="rounded-lg bg-green-100 px-3 py-1 text-xs text-green-700 transition-colors hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400 dark:hover:bg-green-900/50" |
|
|
type="button" |
|
|
@click=" |
|
|
addPresetMapping('claude-3-5-haiku-20241022', 'claude-3-5-haiku-20241022') |
|
|
" |
|
|
> |
|
|
+ Haiku 3.5 |
|
|
</button> |
|
|
<button |
|
|
class="rounded-lg bg-emerald-100 px-3 py-1 text-xs text-emerald-700 transition-colors hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400 dark:hover:bg-emerald-900/50" |
|
|
type="button" |
|
|
@click=" |
|
|
addPresetMapping('claude-haiku-4-5-20251001', 'claude-haiku-4-5-20251001') |
|
|
" |
|
|
> |
|
|
+ Haiku 4.5 |
|
|
</button> |
|
|
<button |
|
|
class="rounded-lg bg-cyan-100 px-3 py-1 text-xs text-cyan-700 transition-colors hover:bg-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-400 dark:hover:bg-cyan-900/50" |
|
|
type="button" |
|
|
@click="addPresetMapping('deepseek-chat', 'deepseek-chat')" |
|
|
> |
|
|
+ DeepSeek |
|
|
</button> |
|
|
<button |
|
|
class="rounded-lg bg-orange-100 px-3 py-1 text-xs text-orange-700 transition-colors hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-400 dark:hover:bg-orange-900/50" |
|
|
type="button" |
|
|
@click="addPresetMapping('Qwen', 'Qwen')" |
|
|
> |
|
|
+ Qwen |
|
|
</button> |
|
|
<button |
|
|
class="rounded-lg bg-pink-100 px-3 py-1 text-xs text-pink-700 transition-colors hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400 dark:hover:bg-pink-900/50" |
|
|
type="button" |
|
|
@click="addPresetMapping('Kimi', 'Kimi')" |
|
|
> |
|
|
+ Kimi |
|
|
</button> |
|
|
<button |
|
|
class="rounded-lg bg-teal-100 px-3 py-1 text-xs text-teal-700 transition-colors hover:bg-teal-200 dark:bg-teal-900/30 dark:text-teal-400 dark:hover:bg-teal-900/50" |
|
|
type="button" |
|
|
@click="addPresetMapping('GLM', 'GLM')" |
|
|
> |
|
|
+ GLM |
|
|
</button> |
|
|
<button |
|
|
class="rounded-lg bg-amber-100 px-3 py-1 text-xs text-amber-700 transition-colors hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400 dark:hover:bg-amber-900/50" |
|
|
type="button" |
|
|
@click=" |
|
|
addPresetMapping('claude-opus-4-1-20250805', 'claude-sonnet-4-20250514') |
|
|
" |
|
|
> |
|
|
+ Opus → Sonnet |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" |
|
|
>自定义 User-Agent (可选)</label |
|
|
> |
|
|
<input |
|
|
v-model="form.userAgent" |
|
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" |
|
|
placeholder="留空则透传客户端 User-Agent" |
|
|
type="text" |
|
|
/> |
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> |
|
|
留空时将自动使用客户端的 User-Agent,仅在需要固定特定 UA 时填写 |
|
|
</p> |
|
|
</div> |
|
|
|
|
|
<div> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" |
|
|
>限流机制</label |
|
|
> |
|
|
<div class="mb-3"> |
|
|
<label class="inline-flex cursor-pointer items-center"> |
|
|
<input |
|
|
v-model="form.enableRateLimit" |
|
|
class="mr-2 rounded border-gray-300 text-blue-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-gray-600 dark:bg-gray-700" |
|
|
type="checkbox" |
|
|
/> |
|
|
<span class="text-sm text-gray-700 dark:text-gray-300">启用限流机制</span> |
|
|
</label> |
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> |
|
|
启用后,当账号返回429错误时将暂停调度一段时间 |
|
|
</p> |
|
|
</div> |
|
|
|
|
|
<div v-if="form.enableRateLimit"> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" |
|
|
>限流时间 (分钟)</label |
|
|
> |
|
|
<input |
|
|
v-model.number="form.rateLimitDuration" |
|
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" |
|
|
min="1" |
|
|
placeholder="默认60分钟" |
|
|
type="number" |
|
|
/> |
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> |
|
|
账号被限流后暂停调度的时间(分钟) |
|
|
</p> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div v-if="form.platform === 'openai-responses' && !isEdit" class="space-y-4"> |
|
|
<div> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" |
|
|
>API 基础地址 *</label |
|
|
> |
|
|
<input |
|
|
v-model="form.baseApi" |
|
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" |
|
|
placeholder="https://api.example.com/v1" |
|
|
required |
|
|
type="url" |
|
|
/> |
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> |
|
|
第三方 OpenAI 兼容 API 的基础地址,不要包含具体路径 |
|
|
</p> |
|
|
</div> |
|
|
|
|
|
<div> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" |
|
|
>API 密钥 *</label |
|
|
> |
|
|
<div class="relative"> |
|
|
<input |
|
|
v-model="form.apiKey" |
|
|
class="form-input w-full border-gray-300 pr-10 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" |
|
|
placeholder="sk-xxxxxxxxxxxx" |
|
|
required |
|
|
:type="showApiKey ? 'text' : 'password'" |
|
|
/> |
|
|
<button |
|
|
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-400" |
|
|
type="button" |
|
|
@click="showApiKey = !showApiKey" |
|
|
> |
|
|
<i :class="showApiKey ? 'fas fa-eye-slash' : 'fas fa-eye'" /> |
|
|
</button> |
|
|
</div> |
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> |
|
|
第三方服务提供的 API 密钥 |
|
|
</p> |
|
|
</div> |
|
|
|
|
|
<div> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" |
|
|
>自定义 User-Agent (可选)</label |
|
|
> |
|
|
<input |
|
|
v-model="form.userAgent" |
|
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" |
|
|
placeholder="留空则透传原始请求的 User-Agent" |
|
|
type="text" |
|
|
/> |
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> |
|
|
可选项。如果设置,所有请求将使用此 User-Agent;否则透传客户端的 User-Agent |
|
|
</p> |
|
|
</div> |
|
|
|
|
|
|
|
|
<input v-model.number="form.rateLimitDuration" type="hidden" value="60" /> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div v-if="form.platform === 'claude'"> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" |
|
|
>订阅类型</label |
|
|
> |
|
|
<div class="flex gap-4"> |
|
|
<label class="flex cursor-pointer items-center"> |
|
|
<input |
|
|
v-model="form.subscriptionType" |
|
|
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700" |
|
|
type="radio" |
|
|
value="claude_max" |
|
|
/> |
|
|
<span class="text-sm text-gray-700 dark:text-gray-300">Claude Max</span> |
|
|
</label> |
|
|
<label class="flex cursor-pointer items-center"> |
|
|
<input |
|
|
v-model="form.subscriptionType" |
|
|
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700" |
|
|
type="radio" |
|
|
value="claude_pro" |
|
|
/> |
|
|
<span class="text-sm text-gray-700 dark:text-gray-300">Claude Pro</span> |
|
|
</label> |
|
|
</div> |
|
|
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400"> |
|
|
<i class="fas fa-info-circle mr-1" /> |
|
|
Pro 账号不支持 Claude Opus 4 模型 |
|
|
</p> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div v-if="form.platform === 'claude'" class="mt-4"> |
|
|
<label class="flex items-start"> |
|
|
<input |
|
|
v-model="form.autoStopOnWarning" |
|
|
class="mt-1 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700" |
|
|
type="checkbox" |
|
|
/> |
|
|
<div class="ml-3"> |
|
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300"> |
|
|
5小时使用量接近限制时自动停止调度 |
|
|
</span> |
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> |
|
|
当系统检测到账户接近5小时使用限制时,自动暂停调度该账户。进入新的时间窗口后会自动恢复调度。 |
|
|
</p> |
|
|
</div> |
|
|
</label> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div v-if="form.platform === 'claude'" class="mt-4"> |
|
|
<label class="flex items-start"> |
|
|
<input |
|
|
v-model="form.useUnifiedUserAgent" |
|
|
class="mt-1 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700" |
|
|
type="checkbox" |
|
|
/> |
|
|
<div class="ml-3"> |
|
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300"> |
|
|
使用统一 Claude Code 版本 |
|
|
</span> |
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> |
|
|
开启后将使用从真实 Claude Code 客户端捕获的统一 User-Agent,提高兼容性 |
|
|
</p> |
|
|
<div v-if="unifiedUserAgent" class="mt-1"> |
|
|
<div class="flex items-center justify-between"> |
|
|
<p class="text-xs text-green-600 dark:text-green-400"> |
|
|
💡 当前统一版本:{{ unifiedUserAgent }} |
|
|
</p> |
|
|
<button |
|
|
class="ml-2 text-xs text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300" |
|
|
:disabled="clearingCache" |
|
|
type="button" |
|
|
@click="clearUnifiedCache" |
|
|
> |
|
|
<i v-if="!clearingCache" class="fas fa-trash-alt mr-1"></i> |
|
|
<div v-else class="loading-spinner mr-1"></div> |
|
|
{{ clearingCache ? '清除中...' : '清除缓存' }} |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
<div v-else class="mt-1"> |
|
|
<p class="text-xs text-gray-500 dark:text-gray-400"> |
|
|
⏳ 等待从 Claude Code 客户端捕获 User-Agent |
|
|
</p> |
|
|
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500"> |
|
|
💡 提示:如果长时间未能捕获,请确认有 Claude Code 客户端正在使用此账户, |
|
|
或联系开发者检查 User-Agent 格式是否发生变化 |
|
|
</p> |
|
|
</div> |
|
|
</div> |
|
|
</label> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div v-if="form.platform === 'claude'" class="mt-4"> |
|
|
<label class="flex items-start"> |
|
|
<input |
|
|
v-model="form.useUnifiedClientId" |
|
|
class="mt-1 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700" |
|
|
type="checkbox" |
|
|
@change="handleUnifiedClientIdChange" |
|
|
/> |
|
|
<div class="ml-3 flex-1"> |
|
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300"> |
|
|
使用统一的客户端标识 |
|
|
</span> |
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> |
|
|
开启后将使用固定的客户端标识,使所有请求看起来来自同一个客户端,减少特征 |
|
|
</p> |
|
|
<div v-if="form.useUnifiedClientId" class="mt-3"> |
|
|
<div |
|
|
class="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800/50" |
|
|
> |
|
|
<div class="mb-2 flex items-center justify-between"> |
|
|
<span class="text-xs font-medium text-gray-600 dark:text-gray-400" |
|
|
>客户端标识 ID</span |
|
|
> |
|
|
<button |
|
|
class="rounded-md bg-blue-100 px-2.5 py-1 text-xs font-medium text-blue-700 transition-colors hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400 dark:hover:bg-blue-900/50" |
|
|
type="button" |
|
|
@click="regenerateClientId" |
|
|
> |
|
|
<i class="fas fa-sync-alt mr-1" /> |
|
|
重新生成 |
|
|
</button> |
|
|
</div> |
|
|
<div class="flex items-center gap-2"> |
|
|
<code |
|
|
class="block w-full select-all break-all rounded bg-gray-100 px-3 py-2 font-mono text-xs text-gray-700 dark:bg-gray-900 dark:text-gray-300" |
|
|
> |
|
|
<span class="text-blue-600 dark:text-blue-400">{{ |
|
|
form.unifiedClientId.substring(0, 8) |
|
|
}}</span |
|
|
><span class="text-gray-500 dark:text-gray-500">{{ |
|
|
form.unifiedClientId.substring(8, 56) |
|
|
}}</span |
|
|
><span class="text-blue-600 dark:text-blue-400">{{ |
|
|
form.unifiedClientId.substring(56) |
|
|
}}</span> |
|
|
</code> |
|
|
</div> |
|
|
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400"> |
|
|
<i class="fas fa-info-circle mr-1 text-blue-500" /> |
|
|
此ID将替换请求中的user_id客户端部分,保留session部分用于粘性会话 |
|
|
</p> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</label> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" |
|
|
>调度优先级 (1-100)</label |
|
|
> |
|
|
<input |
|
|
v-model.number="form.priority" |
|
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" |
|
|
max="100" |
|
|
min="1" |
|
|
placeholder="数字越小优先级越高,默认50" |
|
|
type="number" |
|
|
/> |
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> |
|
|
数字越小优先级越高,建议范围:1-100 |
|
|
</p> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div |
|
|
v-if=" |
|
|
form.addType === 'manual' && |
|
|
form.platform !== 'claude-console' && |
|
|
form.platform !== 'ccr' && |
|
|
form.platform !== 'bedrock' && |
|
|
form.platform !== 'azure_openai' && |
|
|
form.platform !== 'openai-responses' |
|
|
" |
|
|
class="space-y-4 rounded-lg border border-blue-200 bg-blue-50 p-4" |
|
|
> |
|
|
<div class="mb-4 flex items-start gap-3"> |
|
|
<div |
|
|
class="mt-1 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-blue-500" |
|
|
> |
|
|
<i class="fas fa-info text-sm text-white" /> |
|
|
</div> |
|
|
<div> |
|
|
<h5 class="mb-2 font-semibold text-blue-900 dark:text-blue-300"> |
|
|
手动输入 Token |
|
|
</h5> |
|
|
<p |
|
|
v-if="form.platform === 'claude'" |
|
|
class="mb-2 text-sm text-blue-800 dark:text-blue-300" |
|
|
> |
|
|
请输入有效的 Claude Access Token。如果您有 Refresh |
|
|
Token,建议也一并填写以支持自动刷新。 |
|
|
</p> |
|
|
<p |
|
|
v-else-if="form.platform === 'gemini'" |
|
|
class="mb-2 text-sm text-blue-800 dark:text-blue-300" |
|
|
> |
|
|
请输入有效的 Gemini Access Token。如果您有 Refresh |
|
|
Token,建议也一并填写以支持自动刷新。 |
|
|
</p> |
|
|
<p |
|
|
v-else-if="form.platform === 'openai'" |
|
|
class="mb-2 text-sm text-blue-800 dark:text-blue-300" |
|
|
> |
|
|
请输入有效的 OpenAI Access Token。如果您有 Refresh |
|
|
Token,建议也一并填写以支持自动刷新。 |
|
|
</p> |
|
|
<p |
|
|
v-else-if="form.platform === 'droid'" |
|
|
class="mb-2 text-sm text-blue-800 dark:text-blue-300" |
|
|
> |
|
|
请输入有效的 Droid Access Token,并同时提供 Refresh Token 以支持自动刷新。 |
|
|
</p> |
|
|
<div |
|
|
class="mb-2 mt-2 rounded-lg border border-blue-300 bg-white/80 p-3 dark:border-blue-600 dark:bg-gray-800/80" |
|
|
> |
|
|
<p class="mb-1 text-sm font-medium text-blue-900 dark:text-blue-300"> |
|
|
<i class="fas fa-folder-open mr-1" /> |
|
|
获取 Access Token 的方法: |
|
|
</p> |
|
|
<p |
|
|
v-if="form.platform === 'claude'" |
|
|
class="text-xs text-blue-800 dark:text-blue-300" |
|
|
> |
|
|
请从已登录 Claude Code 的机器上获取 |
|
|
<code class="rounded bg-blue-100 px-1 py-0.5 font-mono dark:bg-blue-900/50" |
|
|
>~/.claude/.credentials.json</code |
|
|
> |
|
|
文件中的凭证, 请勿使用 Claude 官网 API Keys 页面的密钥。 |
|
|
</p> |
|
|
<p |
|
|
v-else-if="form.platform === 'gemini'" |
|
|
class="text-xs text-blue-800 dark:text-blue-300" |
|
|
> |
|
|
请从已登录 Gemini CLI 的机器上获取 |
|
|
<code class="rounded bg-blue-100 px-1 py-0.5 font-mono dark:bg-blue-900/50" |
|
|
>~/.config/gemini/credentials.json</code |
|
|
> |
|
|
文件中的凭证。 |
|
|
</p> |
|
|
<p |
|
|
v-else-if="form.platform === 'openai'" |
|
|
class="text-xs text-blue-800 dark:text-blue-300" |
|
|
> |
|
|
请从已登录 OpenAI 账户的机器上获取认证凭证, 或通过 OAuth 授权流程获取 Access |
|
|
Token。 |
|
|
</p> |
|
|
<p |
|
|
v-else-if="form.platform === 'droid'" |
|
|
class="text-xs text-blue-800 dark:text-blue-300" |
|
|
> |
|
|
请从已完成授权的 Droid CLI 或 Factory.ai 导出的凭证中获取 Access Token 与 |
|
|
Refresh Token。 |
|
|
</p> |
|
|
</div> |
|
|
<p |
|
|
v-if="form.platform !== 'droid'" |
|
|
class="text-xs text-blue-600 dark:text-blue-400" |
|
|
> |
|
|
💡 如果未填写 Refresh Token,Token 过期后需要手动更新。 |
|
|
</p> |
|
|
<p v-else class="text-xs text-red-600 dark:text-red-400"> |
|
|
⚠️ Droid 账户必须填写 Refresh Token,缺失将导致无法自动刷新 Access Token。 |
|
|
</p> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div v-if="form.platform === 'openai'"> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" |
|
|
>Access Token (可选)</label |
|
|
> |
|
|
<textarea |
|
|
v-model="form.accessToken" |
|
|
class="form-input w-full resize-none border-gray-300 font-mono text-xs dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" |
|
|
placeholder="可选:如果不填写,系统会自动通过 Refresh Token 获取..." |
|
|
rows="4" |
|
|
/> |
|
|
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400"> |
|
|
<i class="fas fa-info-circle mr-1" /> |
|
|
Access Token 可选填。如果不提供,系统会通过 Refresh Token 自动获取。 |
|
|
</p> |
|
|
</div> |
|
|
|
|
|
<div v-else> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" |
|
|
>Access Token *</label |
|
|
> |
|
|
<textarea |
|
|
v-model="form.accessToken" |
|
|
class="form-input w-full resize-none border-gray-300 font-mono text-xs dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" |
|
|
:class="{ 'border-red-500': errors.accessToken }" |
|
|
placeholder="请输入 Access Token..." |
|
|
required |
|
|
rows="4" |
|
|
/> |
|
|
<p v-if="errors.accessToken" class="mt-1 text-xs text-red-500"> |
|
|
{{ errors.accessToken }} |
|
|
</p> |
|
|
</div> |
|
|
|
|
|
<div v-if="form.platform === 'openai' || form.platform === 'droid'"> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" |
|
|
>Refresh Token *</label |
|
|
> |
|
|
<textarea |
|
|
v-model="form.refreshToken" |
|
|
class="form-input w-full resize-none border-gray-300 font-mono text-xs dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" |
|
|
:class="{ 'border-red-500': errors.refreshToken }" |
|
|
placeholder="请输入 Refresh Token(必填)..." |
|
|
required |
|
|
rows="4" |
|
|
/> |
|
|
<p v-if="errors.refreshToken" class="mt-1 text-xs text-red-500"> |
|
|
{{ errors.refreshToken }} |
|
|
</p> |
|
|
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400"> |
|
|
<i class="fas fa-info-circle mr-1" /> |
|
|
<template v-if="form.platform === 'openai'"> |
|
|
系统将使用 Refresh Token 自动获取 Access Token 和用户信息 |
|
|
</template> |
|
|
<template v-else> |
|
|
系统将使用 Refresh Token 自动刷新 Factory.ai 访问令牌,确保账户保持可用。 |
|
|
</template> |
|
|
</p> |
|
|
</div> |
|
|
|
|
|
<div v-else> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" |
|
|
>Refresh Token (可选)</label |
|
|
> |
|
|
<textarea |
|
|
v-model="form.refreshToken" |
|
|
class="form-input w-full resize-none border-gray-300 font-mono text-xs dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" |
|
|
placeholder="请输入 Refresh Token..." |
|
|
rows="4" |
|
|
/> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div |
|
|
v-if="form.addType === 'apikey' && form.platform === 'droid'" |
|
|
class="space-y-4 rounded-lg border border-purple-200 bg-purple-50 p-4 dark:border-purple-700 dark:bg-purple-900/30" |
|
|
> |
|
|
<div class="mb-4 flex items-start gap-3"> |
|
|
<div |
|
|
class="mt-1 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-purple-500" |
|
|
> |
|
|
<i class="fas fa-key text-sm text-white" /> |
|
|
</div> |
|
|
<div> |
|
|
<h5 class="mb-2 font-semibold text-purple-900 dark:text-purple-200"> |
|
|
使用 API Key 调度 Droid |
|
|
</h5> |
|
|
<p class="text-sm text-purple-800 dark:text-purple-200"> |
|
|
请填写一个或多个 Factory.ai API |
|
|
Key,系统会自动在请求时随机挑选并结合会话哈希维持粘性,确保对话上下文保持稳定。 |
|
|
</p> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" |
|
|
>API Key 列表 *</label |
|
|
> |
|
|
<textarea |
|
|
v-model="form.apiKeysInput" |
|
|
class="form-input w-full resize-none border-gray-300 font-mono text-xs dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" |
|
|
:class="{ 'border-red-500': errors.apiKeys }" |
|
|
placeholder="每行一个 API Key,可粘贴多行" |
|
|
required |
|
|
rows="6" |
|
|
/> |
|
|
<p v-if="errors.apiKeys" class="mt-1 text-xs text-red-500"> |
|
|
{{ errors.apiKeys }} |
|
|
</p> |
|
|
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400"> |
|
|
<i class="fas fa-info-circle mr-1" /> |
|
|
建议为每条 Key 提供独立额度;系统会自动去重并忽略空白行。 |
|
|
</p> |
|
|
</div> |
|
|
|
|
|
<div |
|
|
class="rounded-lg border border-purple-200 bg-white/70 p-3 text-xs text-purple-800 dark:border-purple-700 dark:bg-purple-800/20 dark:text-purple-100" |
|
|
> |
|
|
<p class="font-medium"><i class="fas fa-random mr-1" />分配策略说明</p> |
|
|
<ul class="mt-1 list-disc space-y-1 pl-4"> |
|
|
<li>新会话将随机命中一个 Key,并在会话有效期内保持粘性。</li> |
|
|
<li>若某 Key 失效,会自动切换到剩余可用 Key,最大化成功率。</li> |
|
|
<li> |
|
|
若上游返回 4xx 错误码,该 Key 会被自动标记为异常;全部 Key |
|
|
异常后账号将暂停调度。 |
|
|
</li> |
|
|
</ul> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<ProxyConfig v-model="form.proxy" /> |
|
|
|
|
|
<div class="flex gap-3 pt-4"> |
|
|
<button |
|
|
class="flex-1 rounded-xl bg-gray-100 px-6 py-3 font-semibold text-gray-700 transition-colors hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700" |
|
|
type="button" |
|
|
@click="$emit('close')" |
|
|
> |
|
|
取消 |
|
|
</button> |
|
|
<button |
|
|
v-if=" |
|
|
(form.addType === 'oauth' || form.addType === 'setup-token') && |
|
|
form.platform !== 'claude-console' && |
|
|
form.platform !== 'ccr' && |
|
|
form.platform !== 'bedrock' && |
|
|
form.platform !== 'azure_openai' && |
|
|
form.platform !== 'openai-responses' |
|
|
" |
|
|
class="btn btn-primary flex-1 px-6 py-3 font-semibold" |
|
|
:disabled="loading" |
|
|
type="button" |
|
|
@click="nextStep" |
|
|
> |
|
|
下一步 |
|
|
</button> |
|
|
<button |
|
|
v-else |
|
|
class="btn btn-primary flex-1 px-6 py-3 font-semibold" |
|
|
:disabled="loading" |
|
|
type="button" |
|
|
@click="createAccount" |
|
|
> |
|
|
<div v-if="loading" class="loading-spinner mr-2" /> |
|
|
{{ loading ? '创建中...' : '创建' }} |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<OAuthFlow |
|
|
v-if="oauthStep === 2 && form.addType === 'oauth'" |
|
|
:platform="form.platform" |
|
|
:proxy="form.proxy" |
|
|
@back="oauthStep = 1" |
|
|
@success="handleOAuthSuccess" |
|
|
/> |
|
|
|
|
|
|
|
|
<div v-if="oauthStep === 2 && form.addType === 'setup-token'" class="space-y-6"> |
|
|
|
|
|
<div v-if="form.platform === 'claude'"> |
|
|
<div |
|
|
class="rounded-lg border border-blue-200 bg-blue-50 p-6 dark:border-blue-700 dark:bg-blue-900/30" |
|
|
> |
|
|
<div class="flex items-start gap-4"> |
|
|
<div |
|
|
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-blue-500" |
|
|
> |
|
|
<i class="fas fa-key text-white" /> |
|
|
</div> |
|
|
<div class="flex-1"> |
|
|
<h4 class="mb-3 font-semibold text-blue-900 dark:text-blue-200"> |
|
|
Claude Setup Token 授权 |
|
|
</h4> |
|
|
<p class="mb-4 text-sm text-blue-800 dark:text-blue-300"> |
|
|
请按照以下步骤通过 Setup Token 完成 Claude 账户的授权: |
|
|
</p> |
|
|
|
|
|
<div class="space-y-4"> |
|
|
|
|
|
<div |
|
|
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80" |
|
|
> |
|
|
<div class="flex items-start gap-3"> |
|
|
<div |
|
|
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white" |
|
|
> |
|
|
1 |
|
|
</div> |
|
|
<div class="flex-1"> |
|
|
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200"> |
|
|
点击下方按钮生成授权链接 |
|
|
</p> |
|
|
<button |
|
|
v-if="!setupTokenAuthUrl" |
|
|
class="btn btn-primary px-4 py-2 text-sm" |
|
|
:disabled="setupTokenLoading" |
|
|
@click="generateSetupTokenAuthUrl" |
|
|
> |
|
|
<i v-if="!setupTokenLoading" class="fas fa-link mr-2" /> |
|
|
<div v-else class="loading-spinner mr-2" /> |
|
|
{{ setupTokenLoading ? '生成中...' : '生成 Setup Token 授权链接' }} |
|
|
</button> |
|
|
<div v-else class="space-y-3"> |
|
|
<div class="flex items-center gap-2"> |
|
|
<input |
|
|
class="form-input flex-1 bg-gray-50 font-mono text-xs dark:bg-gray-700" |
|
|
readonly |
|
|
type="text" |
|
|
:value="setupTokenAuthUrl" |
|
|
/> |
|
|
<button |
|
|
class="rounded-lg bg-gray-100 px-3 py-2 transition-colors hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600" |
|
|
title="复制链接" |
|
|
@click="copySetupTokenAuthUrl" |
|
|
> |
|
|
<i |
|
|
:class=" |
|
|
setupTokenCopied ? 'fas fa-check text-green-500' : 'fas fa-copy' |
|
|
" |
|
|
/> |
|
|
</button> |
|
|
</div> |
|
|
<button |
|
|
class="text-xs text-blue-600 hover:text-blue-700" |
|
|
@click="regenerateSetupTokenAuthUrl" |
|
|
> |
|
|
<i class="fas fa-sync-alt mr-1" />重新生成 |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div |
|
|
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80" |
|
|
> |
|
|
<div class="flex items-start gap-3"> |
|
|
<div |
|
|
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white" |
|
|
> |
|
|
2 |
|
|
</div> |
|
|
<div class="flex-1"> |
|
|
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200"> |
|
|
在浏览器中打开链接并完成授权 |
|
|
</p> |
|
|
<p class="mb-2 text-sm text-blue-700 dark:text-blue-300"> |
|
|
请在新标签页中打开授权链接,登录您的 Claude 账户并授权 Claude Code。 |
|
|
</p> |
|
|
<div |
|
|
class="rounded border border-yellow-300 bg-yellow-50 p-3 dark:border-yellow-700 dark:bg-yellow-900/30" |
|
|
> |
|
|
<p class="text-xs text-yellow-800 dark:text-yellow-300"> |
|
|
<i class="fas fa-exclamation-triangle mr-1" /> |
|
|
<strong>注意:</strong |
|
|
>如果您设置了代理,请确保浏览器也使用相同的代理访问授权页面。 |
|
|
</p> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div |
|
|
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80" |
|
|
> |
|
|
<div class="flex items-start gap-3"> |
|
|
<div |
|
|
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white" |
|
|
> |
|
|
3 |
|
|
</div> |
|
|
<div class="flex-1"> |
|
|
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200"> |
|
|
输入 Authorization Code |
|
|
</p> |
|
|
<p class="mb-3 text-sm text-blue-700 dark:text-blue-300"> |
|
|
授权完成后,从返回页面复制 Authorization Code,并粘贴到下方输入框: |
|
|
</p> |
|
|
<div class="space-y-3"> |
|
|
<div> |
|
|
<label |
|
|
class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300" |
|
|
> |
|
|
<i class="fas fa-key mr-2 text-blue-500" />Authorization Code |
|
|
</label> |
|
|
<textarea |
|
|
v-model="setupTokenAuthCode" |
|
|
class="form-input w-full resize-none border-gray-300 font-mono text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" |
|
|
placeholder="粘贴从Claude Code授权页面获取的Authorization Code..." |
|
|
rows="3" |
|
|
/> |
|
|
</div> |
|
|
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400"> |
|
|
<i class="fas fa-info-circle mr-1" /> |
|
|
请粘贴从Claude Code授权页面复制的Authorization Code |
|
|
</p> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="flex gap-3 pt-4"> |
|
|
<button |
|
|
class="flex-1 rounded-xl bg-gray-100 px-6 py-3 font-semibold text-gray-700 transition-colors hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700" |
|
|
type="button" |
|
|
@click="oauthStep = 1" |
|
|
> |
|
|
上一步 |
|
|
</button> |
|
|
<button |
|
|
class="btn btn-primary flex-1 px-6 py-3 font-semibold" |
|
|
:disabled="!canExchangeSetupToken || setupTokenExchanging" |
|
|
type="button" |
|
|
@click="exchangeSetupTokenCode" |
|
|
> |
|
|
<div v-if="setupTokenExchanging" class="loading-spinner mr-2" /> |
|
|
{{ setupTokenExchanging ? '验证中...' : '完成授权' }} |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div v-if="isEdit" class="space-y-6"> |
|
|
|
|
|
<div> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" |
|
|
>账户名称</label |
|
|
> |
|
|
<input |
|
|
v-model="form.name" |
|
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" |
|
|
placeholder="为账户设置一个易识别的名称" |
|
|
required |
|
|
type="text" |
|
|
/> |
|
|
</div> |
|
|
|
|
|
<div> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" |
|
|
>描述 (可选)</label |
|
|
> |
|
|
<textarea |
|
|
v-model="form.description" |
|
|
class="form-input w-full resize-none border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" |
|
|
placeholder="账户用途说明..." |
|
|
rows="3" |
|
|
/> |
|
|
</div> |
|
|
|
|
|
<div> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" |
|
|
>账户类型</label |
|
|
> |
|
|
<div class="flex gap-4"> |
|
|
<label class="flex cursor-pointer items-center"> |
|
|
<input |
|
|
v-model="form.accountType" |
|
|
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700" |
|
|
type="radio" |
|
|
value="shared" |
|
|
/> |
|
|
<span class="text-sm text-gray-700 dark:text-gray-300">共享账户</span> |
|
|
</label> |
|
|
<label class="flex cursor-pointer items-center"> |
|
|
<input |
|
|
v-model="form.accountType" |
|
|
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700" |
|
|
type="radio" |
|
|
value="dedicated" |
|
|
/> |
|
|
<span class="text-sm text-gray-700 dark:text-gray-300">专属账户</span> |
|
|
</label> |
|
|
<label class="flex cursor-pointer items-center"> |
|
|
<input |
|
|
v-model="form.accountType" |
|
|
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700" |
|
|
type="radio" |
|
|
value="group" |
|
|
/> |
|
|
<span class="text-sm text-gray-700 dark:text-gray-300">分组调度</span> |
|
|
</label> |
|
|
</div> |
|
|
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400"> |
|
|
共享账户:供所有API Key使用;专属账户:仅供特定API |
|
|
Key使用;分组调度:加入分组供分组内调度 |
|
|
</p> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div v-if="!isEdit"> |
|
|
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300" |
|
|
>到期时间 (可选)</label |
|
|
> |
|
|
<div |
|
|
class="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800" |
|
|
> |
|
|
<select |
|
|
v-model="form.expireDuration" |
|
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200" |
|
|
@change="updateAccountExpireAt" |
|
|
> |
|
|
<option value="">永不过期</option> |
|
|
<option value="30d">30 天</option> |
|
|
<option value="90d">90 天</option> |
|
|
<option value="180d">180 天</option> |
|
|
<option value="365d">365 天</option> |
|
|
<option value="custom">自定义日期</option> |
|
|
</select> |
|
|
<div v-if="form.expireDuration === 'custom'" class="mt-3"> |
|
|
<input |
|
|
v-model="form.customExpireDate" |
|
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200" |
|
|
:min="minDateTime" |
|
|
type="datetime-local" |
|
|
@change="updateAccountCustomExpireAt" |
|
|
/> |
|
|
</div> |
|
|
<p v-if="form.expiresAt" class="mt-2 text-xs text-gray-500 dark:text-gray-400"> |
|
|
<i class="fas fa-calendar-alt mr-1" /> |
|
|
将于 {{ formatExpireDate(form.expiresAt) }} 过期 |
|
|
</p> |
|
|
<p v-else class="mt-2 text-xs text-gray-500 dark:text-gray-400"> |
|
|
<i class="fas fa-infinity mr-1" /> |
|
|
账户永不过期 |
|
|
</p> |
|
|
</div> |
|
|
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400"> |
|
|
设置 Claude Max/Pro 订阅的到期时间,到期后将停止调度此账户 |
|
|
</p> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div v-if="form.accountType === 'group'"> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" |
|
|
>选择分组 *</label |
|
|
> |
|
|
<div class="flex gap-2"> |
|
|
<div class="flex-1"> |
|
|
|
|
|
<div |
|
|
class="max-h-48 space-y-2 overflow-y-auto rounded-md border p-3 dark:border-gray-600 dark:bg-gray-700" |
|
|
> |
|
|
<div |
|
|
v-if="filteredGroups.length === 0" |
|
|
class="text-sm text-gray-500 dark:text-gray-400" |
|
|
> |
|
|
暂无可用分组 |
|
|
</div> |
|
|
<label |
|
|
v-for="group in filteredGroups" |
|
|
:key="group.id" |
|
|
class="flex cursor-pointer items-center gap-2 rounded-md p-2 hover:bg-gray-50 dark:hover:bg-gray-600" |
|
|
> |
|
|
<input |
|
|
v-model="form.groupIds" |
|
|
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700" |
|
|
type="checkbox" |
|
|
:value="group.id" |
|
|
/> |
|
|
<span class="text-sm text-gray-700 dark:text-gray-200"> |
|
|
{{ group.name }} ({{ group.memberCount || 0 }} 个成员) |
|
|
</span> |
|
|
</label> |
|
|
|
|
|
<div class="border-t pt-2 dark:border-gray-600"> |
|
|
<button |
|
|
class="flex items-center gap-2 text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300" |
|
|
type="button" |
|
|
@click="handleNewGroup" |
|
|
> |
|
|
<i class="fas fa-plus" /> |
|
|
新建分组 |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
<button |
|
|
class="rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600" |
|
|
type="button" |
|
|
@click="refreshGroups" |
|
|
> |
|
|
<i class="fas fa-sync-alt" :class="{ 'animate-spin': loadingGroups }" /> |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div v-if="form.platform === 'gemini'"> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" |
|
|
>项目 ID (可选)</label |
|
|
> |
|
|
<input |
|
|
v-model="form.projectId" |
|
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" |
|
|
placeholder="例如:verdant-wares-464411-k9" |
|
|
type="text" |
|
|
/> |
|
|
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400"> |
|
|
Google Cloud/Workspace 账号可能需要提供项目 ID |
|
|
</p> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div v-if="form.platform === 'claude'"> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" |
|
|
>订阅类型</label |
|
|
> |
|
|
<div class="flex gap-4"> |
|
|
<label class="flex cursor-pointer items-center"> |
|
|
<input |
|
|
v-model="form.subscriptionType" |
|
|
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700" |
|
|
type="radio" |
|
|
value="claude_max" |
|
|
/> |
|
|
<span class="text-sm text-gray-700 dark:text-gray-300">Claude Max</span> |
|
|
</label> |
|
|
<label class="flex cursor-pointer items-center"> |
|
|
<input |
|
|
v-model="form.subscriptionType" |
|
|
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700" |
|
|
type="radio" |
|
|
value="claude_pro" |
|
|
/> |
|
|
<span class="text-sm text-gray-700 dark:text-gray-300">Claude Pro</span> |
|
|
</label> |
|
|
</div> |
|
|
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400"> |
|
|
<i class="fas fa-info-circle mr-1" /> |
|
|
Pro 账号不支持 Claude Opus 4 模型 |
|
|
</p> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div v-if="form.platform === 'claude'" class="mt-4"> |
|
|
<label class="flex items-start"> |
|
|
<input |
|
|
v-model="form.autoStopOnWarning" |
|
|
class="mt-1 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700" |
|
|
type="checkbox" |
|
|
/> |
|
|
<div class="ml-3"> |
|
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300"> |
|
|
5小时使用量接近限制时自动停止调度 |
|
|
</span> |
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> |
|
|
当系统检测到账户接近5小时使用限制时,自动暂停调度该账户。进入新的时间窗口后会自动恢复调度。 |
|
|
</p> |
|
|
</div> |
|
|
</label> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div v-if="form.platform === 'claude'" class="mt-4"> |
|
|
<label class="flex items-start"> |
|
|
<input |
|
|
v-model="form.useUnifiedUserAgent" |
|
|
class="mt-1 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700" |
|
|
type="checkbox" |
|
|
/> |
|
|
<div class="ml-3"> |
|
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300"> |
|
|
使用统一 Claude Code 版本 |
|
|
</span> |
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> |
|
|
开启后将使用从真实 Claude Code 客户端捕获的统一 User-Agent,提高兼容性 |
|
|
</p> |
|
|
<div v-if="unifiedUserAgent" class="mt-1"> |
|
|
<div class="flex items-center justify-between"> |
|
|
<p class="text-xs text-green-600 dark:text-green-400"> |
|
|
💡 当前统一版本:{{ unifiedUserAgent }} |
|
|
</p> |
|
|
<button |
|
|
class="ml-2 text-xs text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300" |
|
|
:disabled="clearingCache" |
|
|
type="button" |
|
|
@click="clearUnifiedCache" |
|
|
> |
|
|
<i v-if="!clearingCache" class="fas fa-trash-alt mr-1"></i> |
|
|
<div v-else class="loading-spinner mr-1"></div> |
|
|
{{ clearingCache ? '清除中...' : '清除缓存' }} |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
<div v-else class="mt-1"> |
|
|
<p class="text-xs text-gray-500 dark:text-gray-400"> |
|
|
⏳ 等待从 Claude Code 客户端捕获 User-Agent |
|
|
</p> |
|
|
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500"> |
|
|
💡 提示:如果长时间未能捕获,请确认有 Claude Code 客户端正在使用此账户, |
|
|
或联系开发者检查 User-Agent 格式是否发生变化 |
|
|
</p> |
|
|
</div> |
|
|
</div> |
|
|
</label> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div v-if="form.platform === 'claude'" class="mt-4"> |
|
|
<label class="flex items-start"> |
|
|
<input |
|
|
v-model="form.useUnifiedClientId" |
|
|
class="mt-1 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700" |
|
|
type="checkbox" |
|
|
@change="handleUnifiedClientIdChange" |
|
|
/> |
|
|
<div class="ml-3 flex-1"> |
|
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300"> |
|
|
使用统一的客户端标识 |
|
|
</span> |
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> |
|
|
开启后将使用固定的客户端标识,使所有请求看起来来自同一个客户端,减少特征 |
|
|
</p> |
|
|
<div v-if="form.useUnifiedClientId" class="mt-3"> |
|
|
<div |
|
|
class="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800/50" |
|
|
> |
|
|
<div class="mb-2 flex items-center justify-between"> |
|
|
<span class="text-xs font-medium text-gray-600 dark:text-gray-400" |
|
|
>客户端标识 ID</span |
|
|
> |
|
|
<button |
|
|
class="rounded-md bg-blue-100 px-2.5 py-1 text-xs font-medium text-blue-700 transition-colors hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400 dark:hover:bg-blue-900/50" |
|
|
type="button" |
|
|
@click="regenerateClientId" |
|
|
> |
|
|
<i class="fas fa-sync-alt mr-1" /> |
|
|
重新生成 |
|
|
</button> |
|
|
</div> |
|
|
<div class="flex items-center gap-2"> |
|
|
<code |
|
|
class="block w-full select-all break-all rounded bg-gray-100 px-3 py-2 font-mono text-xs text-gray-700 dark:bg-gray-900 dark:text-gray-300" |
|
|
> |
|
|
<span class="text-blue-600 dark:text-blue-400">{{ |
|
|
form.unifiedClientId.substring(0, 8) |
|
|
}}</span |
|
|
><span class="text-gray-500 dark:text-gray-500">{{ |
|
|
form.unifiedClientId.substring(8, 56) |
|
|
}}</span |
|
|
><span class="text-blue-600 dark:text-blue-400">{{ |
|
|
form.unifiedClientId.substring(56) |
|
|
}}</span> |
|
|
</code> |
|
|
</div> |
|
|
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400"> |
|
|
<i class="fas fa-info-circle mr-1 text-blue-500" /> |
|
|
此ID将替换请求中的user_id客户端部分,保留session部分用于粘性会话 |
|
|
</p> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</label> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" |
|
|
>调度优先级 (1-100)</label |
|
|
> |
|
|
<input |
|
|
v-model.number="form.priority" |
|
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" |
|
|
max="100" |
|
|
min="1" |
|
|
placeholder="数字越小优先级越高" |
|
|
type="number" |
|
|
/> |
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> |
|
|
数字越小优先级越高,建议范围:1-100 |
|
|
</p> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div |
|
|
v-if="form.platform === 'claude-console' || form.platform === 'ccr'" |
|
|
class="space-y-4" |
|
|
> |
|
|
<div> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700">API URL</label> |
|
|
<input |
|
|
v-model="form.apiUrl" |
|
|
class="form-input w-full" |
|
|
placeholder="例如:https://api.example.com" |
|
|
required |
|
|
type="text" |
|
|
/> |
|
|
</div> |
|
|
|
|
|
<div> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700">API Key</label> |
|
|
<input |
|
|
v-model="form.apiKey" |
|
|
class="form-input w-full" |
|
|
placeholder="留空表示不更新" |
|
|
type="password" |
|
|
/> |
|
|
<p class="mt-1 text-xs text-gray-500">留空表示不更新 API Key</p> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="grid grid-cols-2 gap-4"> |
|
|
<div> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"> |
|
|
每日额度限制 ($) |
|
|
</label> |
|
|
<input |
|
|
v-model.number="form.dailyQuota" |
|
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200" |
|
|
min="0" |
|
|
placeholder="0 表示不限制" |
|
|
step="0.01" |
|
|
type="number" |
|
|
/> |
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> |
|
|
设置每日使用额度,0 表示不限制 |
|
|
</p> |
|
|
</div> |
|
|
|
|
|
<div> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"> |
|
|
额度重置时间 |
|
|
</label> |
|
|
<input |
|
|
v-model="form.quotaResetTime" |
|
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200" |
|
|
placeholder="00:00" |
|
|
type="time" |
|
|
/> |
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">每日自动重置额度的时间</p> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div |
|
|
v-if="isEdit && form.dailyQuota > 0" |
|
|
class="rounded-lg bg-gray-50 p-4 dark:bg-gray-800" |
|
|
> |
|
|
<div class="mb-2 flex items-center justify-between"> |
|
|
<span class="text-sm font-semibold text-gray-700 dark:text-gray-300"> |
|
|
今日使用情况 |
|
|
</span> |
|
|
<span class="text-sm text-gray-500 dark:text-gray-400"> |
|
|
${{ calculateCurrentUsage().toFixed(4) }} / ${{ form.dailyQuota.toFixed(2) }} |
|
|
</span> |
|
|
</div> |
|
|
<div class="relative h-2 w-full rounded-full bg-gray-200 dark:bg-gray-700"> |
|
|
<div |
|
|
class="absolute left-0 top-0 h-full rounded-full transition-all" |
|
|
:class=" |
|
|
usagePercentage >= 90 |
|
|
? 'bg-red-500' |
|
|
: usagePercentage >= 70 |
|
|
? 'bg-yellow-500' |
|
|
: 'bg-green-500' |
|
|
" |
|
|
:style="{ width: `${Math.min(usagePercentage, 100)}%` }" |
|
|
/> |
|
|
</div> |
|
|
<div class="mt-2 flex items-center justify-between text-xs"> |
|
|
<span class="text-gray-500 dark:text-gray-400"> |
|
|
剩余: ${{ Math.max(0, form.dailyQuota - calculateCurrentUsage()).toFixed(2) }} |
|
|
</span> |
|
|
<span class="text-gray-500 dark:text-gray-400"> |
|
|
{{ usagePercentage.toFixed(1) }}% 已使用 |
|
|
</span> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" |
|
|
>模型限制 (可选)</label |
|
|
> |
|
|
|
|
|
|
|
|
<div class="mb-4 flex gap-2"> |
|
|
<button |
|
|
class="flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all" |
|
|
:class=" |
|
|
modelRestrictionMode === 'whitelist' |
|
|
? 'bg-blue-500 text-white shadow-md' |
|
|
: 'border border-gray-300 text-gray-600 hover:border-blue-300 dark:border-gray-600 dark:text-gray-400 dark:hover:border-blue-500' |
|
|
" |
|
|
type="button" |
|
|
@click="modelRestrictionMode = 'whitelist'" |
|
|
> |
|
|
<i class="fas fa-check-circle mr-2" /> |
|
|
模型白名单 |
|
|
</button> |
|
|
<button |
|
|
class="flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all" |
|
|
:class=" |
|
|
modelRestrictionMode === 'mapping' |
|
|
? 'bg-purple-500 text-white shadow-md' |
|
|
: 'border border-gray-300 text-gray-600 hover:border-purple-300 dark:border-gray-600 dark:text-gray-400 dark:hover:border-purple-500' |
|
|
" |
|
|
type="button" |
|
|
@click="modelRestrictionMode = 'mapping'" |
|
|
> |
|
|
<i class="fas fa-random mr-2" /> |
|
|
模型映射 |
|
|
</button> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div v-if="modelRestrictionMode === 'whitelist'"> |
|
|
<div class="mb-3 rounded-lg bg-blue-50 p-3 dark:bg-blue-900/30"> |
|
|
<p class="text-xs text-blue-700 dark:text-blue-400"> |
|
|
<i class="fas fa-info-circle mr-1" /> |
|
|
选择允许使用此账户的模型。留空表示支持所有模型。 |
|
|
</p> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="mb-3 grid grid-cols-2 gap-2"> |
|
|
<label |
|
|
v-for="model in commonModels" |
|
|
:key="model.value" |
|
|
class="flex cursor-pointer items-center rounded-lg border p-3 transition-all hover:bg-gray-50 dark:border-gray-600 dark:hover:bg-gray-700" |
|
|
:class=" |
|
|
allowedModels.includes(model.value) |
|
|
? 'border-blue-500 bg-blue-50 dark:border-blue-400 dark:bg-blue-900/30' |
|
|
: 'border-gray-300' |
|
|
" |
|
|
> |
|
|
<input |
|
|
v-model="allowedModels" |
|
|
class="mr-2 text-blue-600 focus:ring-blue-500" |
|
|
type="checkbox" |
|
|
:value="model.value" |
|
|
/> |
|
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ |
|
|
model.label |
|
|
}}</span> |
|
|
</label> |
|
|
</div> |
|
|
|
|
|
<p class="text-xs text-gray-500 dark:text-gray-400"> |
|
|
已选择 {{ allowedModels.length }} 个模型 |
|
|
<span v-if="allowedModels.length === 0">(支持所有模型)</span> |
|
|
</p> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div v-else> |
|
|
<div class="mb-3 rounded-lg bg-purple-50 p-3 dark:bg-purple-900/30"> |
|
|
<p class="text-xs text-purple-700 dark:text-purple-400"> |
|
|
<i class="fas fa-info-circle mr-1" /> |
|
|
配置模型映射关系。左侧是客户端请求的模型,右侧是实际发送给API的模型。 |
|
|
</p> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="mb-3 space-y-2"> |
|
|
<div |
|
|
v-for="(mapping, index) in modelMappings" |
|
|
:key="index" |
|
|
class="flex items-center gap-2" |
|
|
> |
|
|
<input |
|
|
v-model="mapping.from" |
|
|
class="form-input flex-1 border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200" |
|
|
placeholder="原始模型名称" |
|
|
type="text" |
|
|
/> |
|
|
<i class="fas fa-arrow-right text-gray-400 dark:text-gray-500" /> |
|
|
<input |
|
|
v-model="mapping.to" |
|
|
class="form-input flex-1 border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200" |
|
|
placeholder="映射后的模型名称" |
|
|
type="text" |
|
|
/> |
|
|
<button |
|
|
class="rounded-lg p-2 text-red-500 transition-colors hover:bg-red-50 dark:hover:bg-red-900/20" |
|
|
type="button" |
|
|
@click="removeModelMapping(index)" |
|
|
> |
|
|
<i class="fas fa-trash" /> |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<button |
|
|
class="w-full rounded-lg border-2 border-dashed border-gray-300 px-4 py-2 text-gray-600 transition-colors hover:border-gray-400 hover:text-gray-700 dark:border-gray-600 dark:text-gray-400 dark:hover:border-gray-500" |
|
|
type="button" |
|
|
@click="addModelMapping" |
|
|
> |
|
|
<i class="fas fa-plus mr-2" /> |
|
|
添加模型映射 |
|
|
</button> |
|
|
|
|
|
|
|
|
<div class="mt-3 flex flex-wrap gap-2"> |
|
|
<button |
|
|
class="rounded-lg bg-blue-100 px-3 py-1 text-xs text-blue-700 transition-colors hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400 dark:hover:bg-blue-900/50" |
|
|
type="button" |
|
|
@click=" |
|
|
addPresetMapping('claude-sonnet-4-20250514', 'claude-sonnet-4-20250514') |
|
|
" |
|
|
> |
|
|
+ Sonnet 4 |
|
|
</button> |
|
|
<button |
|
|
class="rounded-lg bg-indigo-100 px-3 py-1 text-xs text-indigo-700 transition-colors hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400 dark:hover:bg-indigo-900/50" |
|
|
type="button" |
|
|
@click=" |
|
|
addPresetMapping('claude-sonnet-4-5-20250929', 'claude-sonnet-4-5-20250929') |
|
|
" |
|
|
> |
|
|
+ Sonnet 4.5 |
|
|
</button> |
|
|
<button |
|
|
class="rounded-lg bg-purple-100 px-3 py-1 text-xs text-purple-700 transition-colors hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400 dark:hover:bg-purple-900/50" |
|
|
type="button" |
|
|
@click=" |
|
|
addPresetMapping('claude-opus-4-1-20250805', 'claude-opus-4-1-20250805') |
|
|
" |
|
|
> |
|
|
+ Opus 4.1 |
|
|
</button> |
|
|
<button |
|
|
class="rounded-lg bg-green-100 px-3 py-1 text-xs text-green-700 transition-colors hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400 dark:hover:bg-green-900/50" |
|
|
type="button" |
|
|
@click=" |
|
|
addPresetMapping('claude-3-5-haiku-20241022', 'claude-3-5-haiku-20241022') |
|
|
" |
|
|
> |
|
|
+ Haiku 3.5 |
|
|
</button> |
|
|
<button |
|
|
class="rounded-lg bg-emerald-100 px-3 py-1 text-xs text-emerald-700 transition-colors hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400 dark:hover:bg-emerald-900/50" |
|
|
type="button" |
|
|
@click=" |
|
|
addPresetMapping('claude-haiku-4-5-20251001', 'claude-haiku-4-5-20251001') |
|
|
" |
|
|
> |
|
|
+ Haiku 4.5 |
|
|
</button> |
|
|
<button |
|
|
class="rounded-lg bg-cyan-100 px-3 py-1 text-xs text-cyan-700 transition-colors hover:bg-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-400 dark:hover:bg-cyan-900/50" |
|
|
type="button" |
|
|
@click="addPresetMapping('deepseek-chat', 'deepseek-chat')" |
|
|
> |
|
|
+ DeepSeek |
|
|
</button> |
|
|
<button |
|
|
class="rounded-lg bg-orange-100 px-3 py-1 text-xs text-orange-700 transition-colors hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-400 dark:hover:bg-orange-900/50" |
|
|
type="button" |
|
|
@click="addPresetMapping('Qwen', 'Qwen')" |
|
|
> |
|
|
+ Qwen |
|
|
</button> |
|
|
<button |
|
|
class="rounded-lg bg-pink-100 px-3 py-1 text-xs text-pink-700 transition-colors hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400 dark:hover:bg-pink-900/50" |
|
|
type="button" |
|
|
@click="addPresetMapping('Kimi', 'Kimi')" |
|
|
> |
|
|
+ Kimi |
|
|
</button> |
|
|
<button |
|
|
class="rounded-lg bg-teal-100 px-3 py-1 text-xs text-teal-700 transition-colors hover:bg-teal-200 dark:bg-teal-900/30 dark:text-teal-400 dark:hover:bg-teal-900/50" |
|
|
type="button" |
|
|
@click="addPresetMapping('GLM', 'GLM')" |
|
|
> |
|
|
+ GLM |
|
|
</button> |
|
|
<button |
|
|
class="rounded-lg bg-amber-100 px-3 py-1 text-xs text-amber-700 transition-colors hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400 dark:hover:bg-amber-900/50" |
|
|
type="button" |
|
|
@click=" |
|
|
addPresetMapping('claude-opus-4-1-20250805', 'claude-sonnet-4-20250514') |
|
|
" |
|
|
> |
|
|
+ Opus → Sonnet |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700" |
|
|
>自定义 User-Agent (可选)</label |
|
|
> |
|
|
<input |
|
|
v-model="form.userAgent" |
|
|
class="form-input w-full" |
|
|
placeholder="留空则透传客户端 User-Agent" |
|
|
type="text" |
|
|
/> |
|
|
<p class="mt-1 text-xs text-gray-500"> |
|
|
留空时将自动使用客户端的 User-Agent,仅在需要固定特定 UA 时填写 |
|
|
</p> |
|
|
</div> |
|
|
|
|
|
<div> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700">限流机制</label> |
|
|
<div class="mb-3"> |
|
|
<label class="inline-flex cursor-pointer items-center"> |
|
|
<input |
|
|
v-model="form.enableRateLimit" |
|
|
class="mr-2 rounded border-gray-300 text-blue-600 focus:border-blue-500 focus:ring focus:ring-blue-200" |
|
|
type="checkbox" |
|
|
/> |
|
|
<span class="text-sm text-gray-700">启用限流机制</span> |
|
|
</label> |
|
|
<p class="mt-1 text-xs text-gray-500"> |
|
|
启用后,当账号返回429错误时将暂停调度一段时间 |
|
|
</p> |
|
|
</div> |
|
|
|
|
|
<div v-if="form.enableRateLimit"> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700" |
|
|
>限流时间 (分钟)</label |
|
|
> |
|
|
<input |
|
|
v-model.number="form.rateLimitDuration" |
|
|
class="form-input w-full" |
|
|
min="1" |
|
|
type="number" |
|
|
/> |
|
|
<p class="mt-1 text-xs text-gray-500">账号被限流后暂停调度的时间(分钟)</p> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div v-if="form.platform === 'openai-responses'" class="space-y-4"> |
|
|
<div> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700">API 基础地址</label> |
|
|
<input |
|
|
v-model="form.baseApi" |
|
|
class="form-input w-full" |
|
|
placeholder="https://api.example.com/v1" |
|
|
type="url" |
|
|
/> |
|
|
</div> |
|
|
|
|
|
<div> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700">API 密钥</label> |
|
|
<div class="relative"> |
|
|
<input |
|
|
v-model="form.apiKey" |
|
|
class="form-input w-full pr-10" |
|
|
placeholder="留空表示不更新" |
|
|
:type="showApiKey ? 'text' : 'password'" |
|
|
/> |
|
|
<button |
|
|
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600" |
|
|
type="button" |
|
|
@click="showApiKey = !showApiKey" |
|
|
> |
|
|
<i :class="showApiKey ? 'fas fa-eye-slash' : 'fas fa-eye'" /> |
|
|
</button> |
|
|
</div> |
|
|
<p class="mt-1 text-xs text-gray-500">留空表示不更新 API Key</p> |
|
|
</div> |
|
|
|
|
|
<div> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700" |
|
|
>自定义 User-Agent</label |
|
|
> |
|
|
<input |
|
|
v-model="form.userAgent" |
|
|
class="form-input w-full" |
|
|
placeholder="留空则透传客户端 User-Agent" |
|
|
type="text" |
|
|
/> |
|
|
<p class="mt-1 text-xs text-gray-500"> |
|
|
留空时将自动使用客户端的 User-Agent,仅在需要固定特定 UA 时填写 |
|
|
</p> |
|
|
</div> |
|
|
|
|
|
|
|
|
<input v-model.number="form.rateLimitDuration" type="hidden" /> |
|
|
|
|
|
|
|
|
<div class="grid grid-cols-2 gap-4"> |
|
|
<div> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"> |
|
|
每日额度限制 ($) |
|
|
</label> |
|
|
<input |
|
|
v-model.number="form.dailyQuota" |
|
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200" |
|
|
min="0" |
|
|
placeholder="0 表示不限制" |
|
|
step="0.01" |
|
|
type="number" |
|
|
/> |
|
|
</div> |
|
|
<div> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"> |
|
|
额度重置时间 |
|
|
</label> |
|
|
<input |
|
|
v-model="form.quotaResetTime" |
|
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200" |
|
|
type="time" |
|
|
/> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div v-if="form.platform === 'bedrock'" class="space-y-4"> |
|
|
<div> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700">AWS 访问密钥 ID</label> |
|
|
<input |
|
|
v-model="form.accessKeyId" |
|
|
class="form-input w-full" |
|
|
placeholder="留空表示不更新" |
|
|
type="text" |
|
|
/> |
|
|
<p class="mt-1 text-xs text-gray-500">留空表示不更新 AWS Access Key ID</p> |
|
|
</div> |
|
|
|
|
|
<div> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700">AWS 秘密访问密钥</label> |
|
|
<input |
|
|
v-model="form.secretAccessKey" |
|
|
class="form-input w-full" |
|
|
placeholder="留空表示不更新" |
|
|
type="password" |
|
|
/> |
|
|
<p class="mt-1 text-xs text-gray-500">留空表示不更新 AWS Secret Access Key</p> |
|
|
</div> |
|
|
|
|
|
<div> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700">AWS 区域</label> |
|
|
<input |
|
|
v-model="form.region" |
|
|
class="form-input w-full" |
|
|
placeholder="例如:us-east-1" |
|
|
type="text" |
|
|
/> |
|
|
<div class="mt-2 rounded-lg border border-blue-200 bg-blue-50 p-3"> |
|
|
<div class="flex items-start gap-2"> |
|
|
<i class="fas fa-info-circle mt-0.5 text-blue-600" /> |
|
|
<div class="text-xs text-blue-700"> |
|
|
<p class="mb-1 font-medium">常用 AWS 区域参考:</p> |
|
|
<div class="grid grid-cols-2 gap-1 text-xs"> |
|
|
<span>• us-east-1 (美国东部)</span> |
|
|
<span>• us-west-2 (美国西部)</span> |
|
|
<span>• eu-west-1 (欧洲爱尔兰)</span> |
|
|
<span>• ap-southeast-1 (新加坡)</span> |
|
|
<span>• ap-northeast-1 (东京)</span> |
|
|
<span>• eu-central-1 (法兰克福)</span> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700">会话令牌 (可选)</label> |
|
|
<input |
|
|
v-model="form.sessionToken" |
|
|
class="form-input w-full" |
|
|
placeholder="留空表示不更新" |
|
|
type="password" |
|
|
/> |
|
|
</div> |
|
|
|
|
|
<div> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700" |
|
|
>默认主模型 (可选)</label |
|
|
> |
|
|
<input |
|
|
v-model="form.defaultModel" |
|
|
class="form-input w-full" |
|
|
placeholder="例如:us.anthropic.claude-sonnet-4-20250514-v1:0" |
|
|
type="text" |
|
|
/> |
|
|
<p class="mt-1 text-xs text-gray-500"> |
|
|
留空将使用系统默认模型。支持 inference profile ID 或 ARN |
|
|
</p> |
|
|
</div> |
|
|
|
|
|
<div> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" |
|
|
>小快速模型 (可选)</label |
|
|
> |
|
|
<input |
|
|
v-model="form.smallFastModel" |
|
|
class="form-input w-full" |
|
|
placeholder="例如:us.anthropic.claude-3-5-haiku-20241022-v1:0" |
|
|
type="text" |
|
|
/> |
|
|
<p class="mt-1 text-xs text-gray-500">用于快速响应的轻量级模型,留空将使用系统默认</p> |
|
|
</div> |
|
|
|
|
|
<div> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700">限流机制</label> |
|
|
<div class="mb-3"> |
|
|
<label class="inline-flex cursor-pointer items-center"> |
|
|
<input |
|
|
v-model="form.enableRateLimit" |
|
|
class="mr-2 rounded border-gray-300 text-blue-600 focus:border-blue-500 focus:ring focus:ring-blue-200" |
|
|
type="checkbox" |
|
|
/> |
|
|
<span class="text-sm text-gray-700">启用限流机制</span> |
|
|
</label> |
|
|
<p class="mt-1 text-xs text-gray-500"> |
|
|
启用后,当账号返回429错误时将暂停调度一段时间 |
|
|
</p> |
|
|
</div> |
|
|
|
|
|
<div v-if="form.enableRateLimit"> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700" |
|
|
>限流时间 (分钟)</label |
|
|
> |
|
|
<input |
|
|
v-model.number="form.rateLimitDuration" |
|
|
class="form-input w-full" |
|
|
min="1" |
|
|
type="number" |
|
|
/> |
|
|
<p class="mt-1 text-xs text-gray-500">账号被限流后暂停调度的时间(分钟)</p> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div v-if="form.platform === 'azure_openai'" class="space-y-4"> |
|
|
<div> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" |
|
|
>Azure Endpoint</label |
|
|
> |
|
|
<input |
|
|
v-model="form.azureEndpoint" |
|
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" |
|
|
:class="{ 'border-red-500': errors.azureEndpoint }" |
|
|
placeholder="https://your-resource.openai.azure.com" |
|
|
type="url" |
|
|
/> |
|
|
<p v-if="errors.azureEndpoint" class="mt-1 text-xs text-red-500"> |
|
|
{{ errors.azureEndpoint }} |
|
|
</p> |
|
|
</div> |
|
|
|
|
|
<div> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" |
|
|
>API 版本</label |
|
|
> |
|
|
<input |
|
|
v-model="form.apiVersion" |
|
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" |
|
|
placeholder="2024-02-01" |
|
|
type="text" |
|
|
/> |
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> |
|
|
Azure OpenAI API 版本,默认使用最新稳定版本 2024-02-01 |
|
|
</p> |
|
|
</div> |
|
|
|
|
|
<div> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" |
|
|
>部署名称</label |
|
|
> |
|
|
<input |
|
|
v-model="form.deploymentName" |
|
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" |
|
|
:class="{ 'border-red-500': errors.deploymentName }" |
|
|
placeholder="gpt-4" |
|
|
type="text" |
|
|
/> |
|
|
<p v-if="errors.deploymentName" class="mt-1 text-xs text-red-500"> |
|
|
{{ errors.deploymentName }} |
|
|
</p> |
|
|
</div> |
|
|
|
|
|
<div> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" |
|
|
>API Key</label |
|
|
> |
|
|
<input |
|
|
v-model="form.apiKey" |
|
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" |
|
|
:class="{ 'border-red-500': errors.apiKey }" |
|
|
placeholder="留空表示不更新" |
|
|
type="password" |
|
|
/> |
|
|
<p v-if="errors.apiKey" class="mt-1 text-xs text-red-500"> |
|
|
{{ errors.apiKey }} |
|
|
</p> |
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">留空表示不更新 API Key</p> |
|
|
</div> |
|
|
|
|
|
<div> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" |
|
|
>支持的模型</label |
|
|
> |
|
|
<div class="flex flex-wrap gap-2"> |
|
|
<label |
|
|
v-for="model in [ |
|
|
'gpt-4', |
|
|
'gpt-4-turbo', |
|
|
'gpt-4o', |
|
|
'gpt-4o-mini', |
|
|
'gpt-5', |
|
|
'gpt-5-mini', |
|
|
'gpt-35-turbo', |
|
|
'gpt-35-turbo-16k', |
|
|
'codex-mini' |
|
|
]" |
|
|
:key="model" |
|
|
class="flex cursor-pointer items-center" |
|
|
> |
|
|
<input |
|
|
v-model="form.supportedModels" |
|
|
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700" |
|
|
type="checkbox" |
|
|
:value="model" |
|
|
/> |
|
|
<span class="text-sm text-gray-700 dark:text-gray-300">{{ model }}</span> |
|
|
</label> |
|
|
</div> |
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">选择此部署支持的模型类型</p> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div |
|
|
v-if="isEdit && isEditingDroidApiKey" |
|
|
class="rounded-lg border border-purple-200 bg-purple-50 p-4 dark:border-purple-700 dark:bg-purple-900/30" |
|
|
> |
|
|
<div class="mb-4 flex items-start gap-3"> |
|
|
<div |
|
|
class="mt-1 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-purple-500" |
|
|
> |
|
|
<i class="fas fa-retweet text-sm text-white" /> |
|
|
</div> |
|
|
<div class="flex-1"> |
|
|
<div class="mb-2 flex items-center justify-between"> |
|
|
<h5 class="font-semibold text-purple-900 dark:text-purple-200">更新 API Key</h5> |
|
|
<button |
|
|
class="flex items-center gap-1.5 rounded-lg bg-purple-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-purple-700 dark:bg-purple-500 dark:hover:bg-purple-600" |
|
|
type="button" |
|
|
@click="showApiKeyManagement = true" |
|
|
> |
|
|
<i class="fas fa-list-ul" /> |
|
|
<span>管理 API Key</span> |
|
|
</button> |
|
|
</div> |
|
|
<p class="mb-1 text-sm text-purple-800 dark:text-purple-200"> |
|
|
当前已保存 <strong>{{ existingApiKeyCount }}</strong> 条 API Key。您可以追加新的 |
|
|
Key,或通过下方模式快速覆盖、删除指定 Key。 |
|
|
</p> |
|
|
<p class="text-xs text-purple-700 dark:text-purple-300"> |
|
|
留空表示保留现有 Key 不变;根据所选模式决定是追加、覆盖还是删除输入的 Key。 |
|
|
</p> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="space-y-4"> |
|
|
<div> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" |
|
|
>新的 API Key 列表</label |
|
|
> |
|
|
<textarea |
|
|
v-model="form.apiKeysInput" |
|
|
class="form-input w-full resize-none border-gray-300 font-mono text-xs dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" |
|
|
:class="{ 'border-red-500': errors.apiKeys }" |
|
|
placeholder="根据模式填写;每行一个 API Key" |
|
|
rows="6" |
|
|
/> |
|
|
<p v-if="errors.apiKeys" class="mt-1 text-xs text-red-500"> |
|
|
{{ errors.apiKeys }} |
|
|
</p> |
|
|
</div> |
|
|
|
|
|
<div class="space-y-2"> |
|
|
<div class="flex items-center justify-between"> |
|
|
<span class="text-sm font-semibold text-purple-800 dark:text-purple-100" |
|
|
>API Key 更新模式</span |
|
|
> |
|
|
<span class="text-xs text-purple-600 dark:text-purple-300"> |
|
|
{{ currentApiKeyModeLabel }} |
|
|
</span> |
|
|
</div> |
|
|
<div |
|
|
class="relative grid h-11 grid-cols-3 overflow-hidden rounded-2xl border border-purple-200/80 bg-gradient-to-r from-purple-50/80 via-white to-purple-50/80 shadow-inner dark:border-purple-700/70 dark:from-purple-900/40 dark:via-purple-900/20 dark:to-purple-900/40" |
|
|
> |
|
|
<span |
|
|
class="pointer-events-none absolute inset-y-0 rounded-2xl bg-gradient-to-r from-purple-500/90 via-purple-600 to-indigo-500/90 shadow-lg ring-1 ring-purple-100/80 transition-all duration-300 ease-out dark:from-purple-500/70 dark:via-purple-600/70 dark:to-indigo-500/70 dark:ring-purple-400/30" |
|
|
:style="apiKeyModeSliderStyle" |
|
|
/> |
|
|
<button |
|
|
v-for="option in apiKeyModeOptions" |
|
|
:key="option.value" |
|
|
class="relative z-10 flex items-center justify-center rounded-2xl px-2 text-xs font-semibold transition-all duration-200 ease-out focus:outline-none focus-visible:ring-2 focus-visible:ring-purple-500/60 dark:focus-visible:ring-purple-400/60" |
|
|
:class=" |
|
|
form.apiKeyUpdateMode === option.value |
|
|
? 'text-white drop-shadow-sm' |
|
|
: 'text-purple-500/80 hover:text-purple-700 dark:text-purple-200/70 dark:hover:text-purple-100' |
|
|
" |
|
|
type="button" |
|
|
@click="form.apiKeyUpdateMode = option.value" |
|
|
> |
|
|
{{ option.label }} |
|
|
</button> |
|
|
</div> |
|
|
<p class="text-xs text-purple-700 dark:text-purple-300"> |
|
|
{{ currentApiKeyModeDescription }} |
|
|
</p> |
|
|
</div> |
|
|
|
|
|
<div |
|
|
class="rounded-lg border border-purple-200 bg-white/70 p-3 text-xs text-purple-800 dark:border-purple-700 dark:bg-purple-800/20 dark:text-purple-100" |
|
|
> |
|
|
<p class="font-medium"><i class="fas fa-lightbulb mr-1" />小提示</p> |
|
|
<ul class="mt-1 list-disc space-y-1 pl-4"> |
|
|
<li>系统会为新的 Key 自动建立粘性映射,保持同一会话命中同一个 Key。</li> |
|
|
<li>追加模式会保留现有 Key 并在末尾追加新的 Key。</li> |
|
|
<li>覆盖模式会先清空旧 Key 再写入上方的新列表。</li> |
|
|
<li>删除模式会根据输入精准移除指定 Key,适合快速处理失效或被封禁的 Key。</li> |
|
|
</ul> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div |
|
|
v-if=" |
|
|
!(isEdit && isEditingDroidApiKey) && |
|
|
form.platform !== 'claude-console' && |
|
|
form.platform !== 'ccr' && |
|
|
form.platform !== 'bedrock' && |
|
|
form.platform !== 'azure_openai' && |
|
|
form.platform !== 'openai-responses' |
|
|
" |
|
|
class="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-700 dark:bg-amber-900/30" |
|
|
> |
|
|
<div class="mb-4 flex items-start gap-3"> |
|
|
<div |
|
|
class="mt-1 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-amber-500" |
|
|
> |
|
|
<i class="fas fa-key text-sm text-white" /> |
|
|
</div> |
|
|
<div> |
|
|
<h5 class="mb-2 font-semibold text-amber-900 dark:text-amber-300">更新 Token</h5> |
|
|
<p class="mb-2 text-sm text-amber-800 dark:text-amber-300"> |
|
|
可以更新 Access Token 和 Refresh Token。为了安全起见,不会显示当前的 Token 值。 |
|
|
</p> |
|
|
<p class="text-xs text-amber-600 dark:text-amber-400">💡 留空表示不更新该字段。</p> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="space-y-4"> |
|
|
<div> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" |
|
|
>新的 Access Token</label |
|
|
> |
|
|
<textarea |
|
|
v-model="form.accessToken" |
|
|
class="form-input w-full resize-none border-gray-300 font-mono text-xs dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" |
|
|
placeholder="留空表示不更新..." |
|
|
rows="4" |
|
|
/> |
|
|
</div> |
|
|
|
|
|
<div> |
|
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" |
|
|
>新的 Refresh Token</label |
|
|
> |
|
|
<textarea |
|
|
v-model="form.refreshToken" |
|
|
class="form-input w-full resize-none border-gray-300 font-mono text-xs dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" |
|
|
placeholder="留空表示不更新..." |
|
|
rows="4" |
|
|
/> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<ProxyConfig v-model="form.proxy" /> |
|
|
|
|
|
<div class="flex gap-3 pt-4"> |
|
|
<button |
|
|
class="flex-1 rounded-xl bg-gray-100 px-6 py-3 font-semibold text-gray-700 transition-colors hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700" |
|
|
type="button" |
|
|
@click="$emit('close')" |
|
|
> |
|
|
取消 |
|
|
</button> |
|
|
<button |
|
|
class="btn btn-primary flex-1 px-6 py-3 font-semibold" |
|
|
:disabled="loading" |
|
|
type="button" |
|
|
@click="updateAccount" |
|
|
> |
|
|
<div v-if="loading" class="loading-spinner mr-2" /> |
|
|
{{ loading ? '更新中...' : '更新' }} |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<ConfirmModal |
|
|
:cancel-text="confirmOptions.cancelText" |
|
|
:confirm-text="confirmOptions.confirmText" |
|
|
:message="confirmOptions.message" |
|
|
:show="showConfirmModal" |
|
|
:title="confirmOptions.title" |
|
|
@cancel="handleCancel" |
|
|
@confirm="handleConfirm" |
|
|
/> |
|
|
|
|
|
|
|
|
<GroupManagementModal |
|
|
v-if="showGroupManagement" |
|
|
@close="showGroupManagement = false" |
|
|
@refresh="handleGroupRefresh" |
|
|
/> |
|
|
|
|
|
|
|
|
<ApiKeyManagementModal |
|
|
v-if="showApiKeyManagement" |
|
|
:account-id="props.account?.id" |
|
|
:account-name="props.account?.name" |
|
|
@close="showApiKeyManagement = false" |
|
|
@refresh="handleApiKeyRefresh" |
|
|
/> |
|
|
</Teleport> |
|
|
</template> |
|
|
|
|
|
<script setup> |
|
|
import { ref, computed, watch, onMounted } from 'vue' |
|
|
import { showToast } from '@/utils/toast' |
|
|
import { apiClient } from '@/config/api' |
|
|
import { useAccountsStore } from '@/stores/accounts' |
|
|
import { useConfirm } from '@/composables/useConfirm' |
|
|
import ProxyConfig from './ProxyConfig.vue' |
|
|
import OAuthFlow from './OAuthFlow.vue' |
|
|
import ConfirmModal from '@/components/common/ConfirmModal.vue' |
|
|
import GroupManagementModal from './GroupManagementModal.vue' |
|
|
import ApiKeyManagementModal from './ApiKeyManagementModal.vue' |
|
|
|
|
|
const props = defineProps({ |
|
|
account: { |
|
|
type: Object, |
|
|
default: null |
|
|
} |
|
|
}) |
|
|
|
|
|
const emit = defineEmits(['close', 'success', 'platform-changed']) |
|
|
|
|
|
const accountsStore = useAccountsStore() |
|
|
const { showConfirmModal, confirmOptions, showConfirm, handleConfirm, handleCancel } = useConfirm() |
|
|
|
|
|
|
|
|
const isEdit = computed(() => !!props.account) |
|
|
const show = ref(true) |
|
|
|
|
|
|
|
|
const oauthStep = ref(1) |
|
|
const loading = ref(false) |
|
|
const showApiKey = ref(false) |
|
|
|
|
|
|
|
|
const setupTokenLoading = ref(false) |
|
|
const setupTokenExchanging = ref(false) |
|
|
const setupTokenAuthUrl = ref('') |
|
|
const setupTokenAuthCode = ref('') |
|
|
const setupTokenCopied = ref(false) |
|
|
const setupTokenSessionId = ref('') |
|
|
|
|
|
|
|
|
const unifiedUserAgent = ref('') |
|
|
const clearingCache = ref(false) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const platformGroup = ref('') |
|
|
|
|
|
|
|
|
const showApiKeyManagement = ref(false) |
|
|
|
|
|
|
|
|
const determinePlatformGroup = (platform) => { |
|
|
if (['claude', 'claude-console', 'ccr', 'bedrock'].includes(platform)) { |
|
|
return 'claude' |
|
|
} else if (['openai', 'openai-responses', 'azure_openai'].includes(platform)) { |
|
|
return 'openai' |
|
|
} else if (platform === 'gemini') { |
|
|
return 'gemini' |
|
|
} else if (platform === 'droid') { |
|
|
return 'droid' |
|
|
} |
|
|
return '' |
|
|
} |
|
|
|
|
|
const createDefaultProxyState = () => ({ |
|
|
enabled: false, |
|
|
type: 'socks5', |
|
|
host: '', |
|
|
port: '', |
|
|
username: '', |
|
|
password: '' |
|
|
}) |
|
|
|
|
|
const parseProxyResponse = (rawProxy) => { |
|
|
if (!rawProxy) { |
|
|
return null |
|
|
} |
|
|
|
|
|
let proxyObject = rawProxy |
|
|
if (typeof rawProxy === 'string') { |
|
|
try { |
|
|
proxyObject = JSON.parse(rawProxy) |
|
|
} catch (error) { |
|
|
return null |
|
|
} |
|
|
} |
|
|
|
|
|
if ( |
|
|
proxyObject && |
|
|
typeof proxyObject === 'object' && |
|
|
proxyObject.proxy && |
|
|
typeof proxyObject.proxy === 'object' |
|
|
) { |
|
|
proxyObject = proxyObject.proxy |
|
|
} |
|
|
|
|
|
if (!proxyObject || typeof proxyObject !== 'object') { |
|
|
return null |
|
|
} |
|
|
|
|
|
const host = |
|
|
typeof proxyObject.host === 'string' |
|
|
? proxyObject.host.trim() |
|
|
: proxyObject.host !== undefined && proxyObject.host !== null |
|
|
? String(proxyObject.host).trim() |
|
|
: '' |
|
|
|
|
|
const port = |
|
|
proxyObject.port !== undefined && proxyObject.port !== null |
|
|
? String(proxyObject.port).trim() |
|
|
: '' |
|
|
|
|
|
const type = |
|
|
typeof proxyObject.type === 'string' && proxyObject.type.trim() |
|
|
? proxyObject.type.trim() |
|
|
: 'socks5' |
|
|
|
|
|
const username = |
|
|
typeof proxyObject.username === 'string' |
|
|
? proxyObject.username |
|
|
: proxyObject.username !== undefined && proxyObject.username !== null |
|
|
? String(proxyObject.username) |
|
|
: '' |
|
|
|
|
|
const password = |
|
|
typeof proxyObject.password === 'string' |
|
|
? proxyObject.password |
|
|
: proxyObject.password !== undefined && proxyObject.password !== null |
|
|
? String(proxyObject.password) |
|
|
: '' |
|
|
|
|
|
return { |
|
|
type, |
|
|
host, |
|
|
port, |
|
|
username, |
|
|
password |
|
|
} |
|
|
} |
|
|
|
|
|
const normalizeProxyFormState = (rawProxy) => { |
|
|
const parsed = parseProxyResponse(rawProxy) |
|
|
|
|
|
if (parsed && parsed.host && parsed.port) { |
|
|
return { |
|
|
enabled: true, |
|
|
type: parsed.type || 'socks5', |
|
|
host: parsed.host, |
|
|
port: parsed.port, |
|
|
username: parsed.username || '', |
|
|
password: parsed.password || '' |
|
|
} |
|
|
} |
|
|
|
|
|
return createDefaultProxyState() |
|
|
} |
|
|
|
|
|
const buildProxyPayload = (proxyState) => { |
|
|
if (!proxyState || !proxyState.enabled) { |
|
|
return null |
|
|
} |
|
|
|
|
|
const host = (proxyState.host || '').trim() |
|
|
const portNumber = Number.parseInt(proxyState.port, 10) |
|
|
|
|
|
if (!host || Number.isNaN(portNumber) || portNumber <= 0) { |
|
|
return null |
|
|
} |
|
|
|
|
|
const username = proxyState.username ? proxyState.username.trim() : '' |
|
|
const password = proxyState.password ? proxyState.password.trim() : '' |
|
|
|
|
|
return { |
|
|
type: proxyState.type || 'socks5', |
|
|
host, |
|
|
port: portNumber, |
|
|
username: username || null, |
|
|
password: password || null |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const initProxyConfig = () => { |
|
|
return normalizeProxyFormState(props.account?.proxy) |
|
|
} |
|
|
|
|
|
|
|
|
const form = ref({ |
|
|
platform: props.account?.platform || 'claude', |
|
|
addType: (() => { |
|
|
const platform = props.account?.platform || 'claude' |
|
|
if (platform === 'gemini' || platform === 'openai') return 'oauth' |
|
|
if (platform === 'claude') return 'oauth' |
|
|
return 'manual' |
|
|
})(), |
|
|
name: props.account?.name || '', |
|
|
description: props.account?.description || '', |
|
|
accountType: props.account?.accountType || 'shared', |
|
|
authenticationMethod: props.account?.authenticationMethod || '', |
|
|
subscriptionType: 'claude_max', |
|
|
autoStopOnWarning: props.account?.autoStopOnWarning || false, |
|
|
useUnifiedUserAgent: props.account?.useUnifiedUserAgent || false, |
|
|
useUnifiedClientId: props.account?.useUnifiedClientId || false, |
|
|
unifiedClientId: props.account?.unifiedClientId || '', |
|
|
groupId: '', |
|
|
groupIds: [], |
|
|
projectId: props.account?.projectId || '', |
|
|
accessToken: '', |
|
|
refreshToken: '', |
|
|
apiKeysInput: '', |
|
|
apiKeyUpdateMode: 'append', |
|
|
proxy: initProxyConfig(), |
|
|
|
|
|
apiUrl: props.account?.apiUrl || '', |
|
|
apiKey: props.account?.apiKey || '', |
|
|
priority: props.account?.priority || 50, |
|
|
endpointType: props.account?.endpointType || 'anthropic', |
|
|
|
|
|
baseApi: props.account?.baseApi || '', |
|
|
rateLimitDuration: props.account?.rateLimitDuration || 60, |
|
|
supportedModels: (() => { |
|
|
const models = props.account?.supportedModels |
|
|
if (!models) return [] |
|
|
|
|
|
if (typeof models === 'object' && !Array.isArray(models)) { |
|
|
return Object.keys(models) |
|
|
} |
|
|
|
|
|
if (Array.isArray(models)) { |
|
|
return models |
|
|
} |
|
|
return [] |
|
|
})(), |
|
|
userAgent: props.account?.userAgent || '', |
|
|
enableRateLimit: props.account ? props.account.rateLimitDuration > 0 : true, |
|
|
|
|
|
dailyQuota: props.account?.dailyQuota || 0, |
|
|
dailyUsage: props.account?.dailyUsage || 0, |
|
|
quotaResetTime: props.account?.quotaResetTime || '00:00', |
|
|
|
|
|
accessKeyId: props.account?.accessKeyId || '', |
|
|
secretAccessKey: props.account?.secretAccessKey || '', |
|
|
region: props.account?.region || '', |
|
|
sessionToken: props.account?.sessionToken || '', |
|
|
defaultModel: props.account?.defaultModel || '', |
|
|
smallFastModel: props.account?.smallFastModel || '', |
|
|
|
|
|
azureEndpoint: props.account?.azureEndpoint || '', |
|
|
apiVersion: props.account?.apiVersion || '', |
|
|
deploymentName: props.account?.deploymentName || '', |
|
|
|
|
|
expireDuration: (() => { |
|
|
|
|
|
if (props.account?.expiresAt) { |
|
|
return 'custom' |
|
|
} |
|
|
return '' |
|
|
})(), |
|
|
customExpireDate: (() => { |
|
|
|
|
|
if (props.account?.expiresAt) { |
|
|
|
|
|
return new Date(props.account.expiresAt).toISOString().slice(0, 16) |
|
|
} |
|
|
return '' |
|
|
})(), |
|
|
expiresAt: props.account?.expiresAt || null |
|
|
}) |
|
|
|
|
|
|
|
|
const modelRestrictionMode = ref('whitelist') |
|
|
const allowedModels = ref([ |
|
|
|
|
|
'claude-sonnet-4-20250514', |
|
|
'claude-sonnet-4-5-20250929', |
|
|
'claude-3-5-haiku-20241022' |
|
|
]) |
|
|
|
|
|
|
|
|
const commonModels = [ |
|
|
{ value: 'claude-sonnet-4-20250514', label: 'Claude Sonnet 4', color: 'blue' }, |
|
|
{ value: 'claude-sonnet-4-5-20250929', label: 'Claude Sonnet 4.5', color: 'indigo' }, |
|
|
{ value: 'claude-3-5-haiku-20241022', label: 'Claude 3.5 Haiku', color: 'green' }, |
|
|
{ value: 'claude-haiku-4-5-20251001', label: 'Claude Haiku 4.5', color: 'emerald' }, |
|
|
{ value: 'claude-opus-4-20250514', label: 'Claude Opus 4', color: 'purple' }, |
|
|
{ value: 'claude-opus-4-1-20250805', label: 'Claude Opus 4.1', color: 'purple' }, |
|
|
{ value: 'deepseek-chat', label: 'DeepSeek Chat', color: 'cyan' }, |
|
|
{ value: 'Qwen', label: 'Qwen', color: 'orange' }, |
|
|
{ value: 'Kimi', label: 'Kimi', color: 'pink' }, |
|
|
{ value: 'GLM', label: 'GLM', color: 'teal' } |
|
|
] |
|
|
|
|
|
|
|
|
const modelMappings = ref([]) |
|
|
|
|
|
|
|
|
const initModelMappings = () => { |
|
|
if (props.account?.supportedModels) { |
|
|
|
|
|
if ( |
|
|
typeof props.account.supportedModels === 'object' && |
|
|
!Array.isArray(props.account.supportedModels) |
|
|
) { |
|
|
const entries = Object.entries(props.account.supportedModels) |
|
|
|
|
|
|
|
|
|
|
|
const isWhitelist = entries.every(([from, to]) => from === to) |
|
|
if (isWhitelist) { |
|
|
modelRestrictionMode.value = 'whitelist' |
|
|
|
|
|
allowedModels.value = entries.map(([from]) => from) |
|
|
|
|
|
modelMappings.value = entries.map(([from, to]) => ({ from, to })) |
|
|
} else { |
|
|
modelRestrictionMode.value = 'mapping' |
|
|
|
|
|
modelMappings.value = entries.map(([from, to]) => ({ from, to })) |
|
|
|
|
|
} |
|
|
} else if (Array.isArray(props.account.supportedModels)) { |
|
|
|
|
|
modelRestrictionMode.value = 'whitelist' |
|
|
allowedModels.value = props.account.supportedModels |
|
|
|
|
|
modelMappings.value = props.account.supportedModels.map((model) => ({ |
|
|
from: model, |
|
|
to: model |
|
|
})) |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const parseApiKeysInput = (input) => { |
|
|
if (!input || typeof input !== 'string') { |
|
|
return [] |
|
|
} |
|
|
|
|
|
const segments = input |
|
|
.split(/\r?\n/) |
|
|
.map((item) => item.trim()) |
|
|
.filter((item) => item.length > 0) |
|
|
|
|
|
if (segments.length === 0) { |
|
|
return [] |
|
|
} |
|
|
|
|
|
const uniqueKeys = Array.from(new Set(segments)) |
|
|
return uniqueKeys |
|
|
} |
|
|
|
|
|
const apiKeyModeOptions = [ |
|
|
{ |
|
|
value: 'append', |
|
|
label: '追加模式', |
|
|
description: '保留现有 Key,并在末尾追加新 Key 列表。' |
|
|
}, |
|
|
{ |
|
|
value: 'replace', |
|
|
label: '覆盖模式', |
|
|
description: '先清空旧 Key,再写入上方的新 Key 列表。' |
|
|
}, |
|
|
{ |
|
|
value: 'delete', |
|
|
label: '删除模式', |
|
|
description: '输入要移除的 Key,可精准删除失效或被封禁的 Key。' |
|
|
} |
|
|
] |
|
|
|
|
|
const apiKeyModeSliderStyle = computed(() => { |
|
|
const index = Math.max( |
|
|
apiKeyModeOptions.findIndex((option) => option.value === form.value.apiKeyUpdateMode), |
|
|
0 |
|
|
) |
|
|
const widthPercent = 100 / apiKeyModeOptions.length |
|
|
|
|
|
return { |
|
|
width: `${widthPercent}%`, |
|
|
left: `${index * widthPercent}%` |
|
|
} |
|
|
}) |
|
|
|
|
|
const currentApiKeyModeLabel = computed(() => { |
|
|
const option = apiKeyModeOptions.find((item) => item.value === form.value.apiKeyUpdateMode) |
|
|
return option ? option.label : apiKeyModeOptions[0].label |
|
|
}) |
|
|
|
|
|
const currentApiKeyModeDescription = computed(() => { |
|
|
const option = apiKeyModeOptions.find((item) => item.value === form.value.apiKeyUpdateMode) |
|
|
return option ? option.description : apiKeyModeOptions[0].description |
|
|
}) |
|
|
|
|
|
|
|
|
const errors = ref({ |
|
|
name: '', |
|
|
refreshToken: '', |
|
|
accessToken: '', |
|
|
apiKeys: '', |
|
|
apiUrl: '', |
|
|
apiKey: '', |
|
|
baseApi: '', |
|
|
accessKeyId: '', |
|
|
secretAccessKey: '', |
|
|
region: '', |
|
|
azureEndpoint: '', |
|
|
deploymentName: '' |
|
|
}) |
|
|
|
|
|
|
|
|
const canProceed = computed(() => { |
|
|
return form.value.name?.trim() && form.value.platform |
|
|
}) |
|
|
|
|
|
|
|
|
const canExchangeSetupToken = computed(() => { |
|
|
return setupTokenAuthUrl.value && setupTokenAuthCode.value.trim() |
|
|
}) |
|
|
|
|
|
|
|
|
const calculateCurrentUsage = () => { |
|
|
|
|
|
if (!isEdit.value || !props.account?.id) { |
|
|
return 0 |
|
|
} |
|
|
|
|
|
|
|
|
if (typeof form.value.dailyUsage === 'number') { |
|
|
return form.value.dailyUsage |
|
|
} |
|
|
|
|
|
return 0 |
|
|
} |
|
|
|
|
|
|
|
|
const usagePercentage = computed(() => { |
|
|
if (!form.value.dailyQuota || form.value.dailyQuota <= 0) { |
|
|
return 0 |
|
|
} |
|
|
const currentUsage = calculateCurrentUsage() |
|
|
return (currentUsage / form.value.dailyQuota) * 100 |
|
|
}) |
|
|
|
|
|
|
|
|
const existingApiKeyCount = computed(() => { |
|
|
if (!props.account || props.account.platform !== 'droid') { |
|
|
return 0 |
|
|
} |
|
|
|
|
|
let fallbackList = 0 |
|
|
|
|
|
if (Array.isArray(props.account.apiKeys)) { |
|
|
fallbackList = props.account.apiKeys.length |
|
|
} else if (typeof props.account.apiKeys === 'string') { |
|
|
try { |
|
|
const parsed = JSON.parse(props.account.apiKeys) |
|
|
if (Array.isArray(parsed)) { |
|
|
fallbackList = parsed.length |
|
|
} |
|
|
} catch (error) { |
|
|
fallbackList = 0 |
|
|
} |
|
|
} |
|
|
|
|
|
const count = |
|
|
props.account.apiKeyCount ?? |
|
|
props.account.apiKeysCount ?? |
|
|
props.account.api_key_count ?? |
|
|
fallbackList |
|
|
|
|
|
return Number(count) || 0 |
|
|
}) |
|
|
|
|
|
|
|
|
const isEditingDroidApiKey = computed(() => { |
|
|
if (!isEdit.value || form.value.platform !== 'droid') { |
|
|
return false |
|
|
} |
|
|
const method = |
|
|
form.value.authenticationMethod || |
|
|
props.account?.authenticationMethod || |
|
|
props.account?.authMethod || |
|
|
props.account?.authentication_mode || |
|
|
'' |
|
|
|
|
|
if (typeof method !== 'string') { |
|
|
return false |
|
|
} |
|
|
|
|
|
return method.trim().toLowerCase() === 'api_key' |
|
|
}) |
|
|
|
|
|
|
|
|
const loadAccountUsage = async () => { |
|
|
if (!isEdit.value || !props.account?.id) return |
|
|
|
|
|
try { |
|
|
const response = await apiClient.get(`/admin/claude-console-accounts/${props.account.id}/usage`) |
|
|
if (response) { |
|
|
|
|
|
form.value.dailyUsage = response.dailyUsage || 0 |
|
|
} |
|
|
} catch (error) { |
|
|
|
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const selectPlatformGroup = (group) => { |
|
|
platformGroup.value = group |
|
|
|
|
|
if (group === 'claude') { |
|
|
form.value.platform = 'claude' |
|
|
} else if (group === 'openai') { |
|
|
form.value.platform = 'openai' |
|
|
} else if (group === 'gemini') { |
|
|
form.value.platform = 'gemini' |
|
|
} else if (group === 'droid') { |
|
|
form.value.platform = 'droid' |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const nextStep = async () => { |
|
|
|
|
|
errors.value.name = '' |
|
|
|
|
|
if (!canProceed.value) { |
|
|
if (!form.value.name || form.value.name.trim() === '') { |
|
|
errors.value.name = '请填写账户名称' |
|
|
} |
|
|
return |
|
|
} |
|
|
|
|
|
|
|
|
if ( |
|
|
form.value.accountType === 'group' && |
|
|
(!form.value.groupIds || form.value.groupIds.length === 0) |
|
|
) { |
|
|
showToast('请选择一个分组', 'error') |
|
|
return |
|
|
} |
|
|
|
|
|
|
|
|
if (form.value.accountType === 'group') { |
|
|
if (form.value.groupIds && form.value.groupIds.length > 0) { |
|
|
form.value.groupId = form.value.groupIds[0] |
|
|
} else { |
|
|
form.value.groupId = '' |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
if (form.value.platform === 'gemini' && oauthStep.value === 1 && form.value.addType === 'oauth') { |
|
|
if (!form.value.projectId || form.value.projectId.trim() === '') { |
|
|
|
|
|
const confirmed = await showConfirm( |
|
|
'项目 ID 未填写', |
|
|
'您尚未填写项目 ID。\n\n如果您的Google账号绑定了Google Cloud或被识别为Workspace账号,需要提供项目 ID。\n如果您使用的是普通个人账号,可以继续不填写。', |
|
|
'继续', |
|
|
'返回填写' |
|
|
) |
|
|
if (!confirmed) { |
|
|
return |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
oauthStep.value = 2 |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
const generateSetupTokenAuthUrl = async () => { |
|
|
setupTokenLoading.value = true |
|
|
try { |
|
|
const proxyPayload = buildProxyPayload(form.value.proxy) |
|
|
const proxyConfig = proxyPayload ? { proxy: proxyPayload } : {} |
|
|
|
|
|
const result = await accountsStore.generateClaudeSetupTokenUrl(proxyConfig) |
|
|
setupTokenAuthUrl.value = result.authUrl |
|
|
setupTokenSessionId.value = result.sessionId |
|
|
} catch (error) { |
|
|
showToast(error.message || '生成Setup Token授权链接失败', 'error') |
|
|
} finally { |
|
|
setupTokenLoading.value = false |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const regenerateSetupTokenAuthUrl = () => { |
|
|
setupTokenAuthUrl.value = '' |
|
|
setupTokenAuthCode.value = '' |
|
|
generateSetupTokenAuthUrl() |
|
|
} |
|
|
|
|
|
|
|
|
const copySetupTokenAuthUrl = async () => { |
|
|
try { |
|
|
await navigator.clipboard.writeText(setupTokenAuthUrl.value) |
|
|
setupTokenCopied.value = true |
|
|
showToast('链接已复制', 'success') |
|
|
setTimeout(() => { |
|
|
setupTokenCopied.value = false |
|
|
}, 2000) |
|
|
} catch (error) { |
|
|
|
|
|
const textarea = document.createElement('textarea') |
|
|
textarea.value = setupTokenAuthUrl.value |
|
|
textarea.style.position = 'fixed' |
|
|
textarea.style.opacity = '0' |
|
|
document.body.appendChild(textarea) |
|
|
textarea.select() |
|
|
|
|
|
try { |
|
|
|
|
|
const successful = document.execCommand('copy') |
|
|
if (successful) { |
|
|
setupTokenCopied.value = true |
|
|
showToast('链接已复制', 'success') |
|
|
} else { |
|
|
showToast('复制失败,请手动复制', 'error') |
|
|
} |
|
|
} catch (err) { |
|
|
showToast('复制失败,请手动复制', 'error') |
|
|
} |
|
|
|
|
|
document.body.removeChild(textarea) |
|
|
setTimeout(() => { |
|
|
setupTokenCopied.value = false |
|
|
}, 2000) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const exchangeSetupTokenCode = async () => { |
|
|
if (!canExchangeSetupToken.value) return |
|
|
|
|
|
setupTokenExchanging.value = true |
|
|
try { |
|
|
const data = { |
|
|
sessionId: setupTokenSessionId.value, |
|
|
callbackUrl: setupTokenAuthCode.value.trim() |
|
|
} |
|
|
|
|
|
|
|
|
const proxyPayload = buildProxyPayload(form.value.proxy) |
|
|
if (proxyPayload) { |
|
|
data.proxy = proxyPayload |
|
|
} |
|
|
|
|
|
const tokenInfo = await accountsStore.exchangeClaudeSetupTokenCode(data) |
|
|
|
|
|
|
|
|
if (form.value.useUnifiedClientId && !form.value.unifiedClientId) { |
|
|
form.value.unifiedClientId = generateClientId() |
|
|
} |
|
|
|
|
|
|
|
|
await handleOAuthSuccess(tokenInfo) |
|
|
} catch (error) { |
|
|
showToast(error.message || 'Setup Token授权失败,请检查授权码是否正确', 'error') |
|
|
} finally { |
|
|
setupTokenExchanging.value = false |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const handleOAuthSuccess = async (tokenInfo) => { |
|
|
loading.value = true |
|
|
try { |
|
|
|
|
|
if ( |
|
|
form.value.platform === 'claude' && |
|
|
form.value.useUnifiedClientId && |
|
|
!form.value.unifiedClientId |
|
|
) { |
|
|
form.value.unifiedClientId = generateClientId() |
|
|
} |
|
|
|
|
|
const proxyPayload = buildProxyPayload(form.value.proxy) |
|
|
|
|
|
const data = { |
|
|
name: form.value.name, |
|
|
description: form.value.description, |
|
|
accountType: form.value.accountType, |
|
|
groupId: form.value.accountType === 'group' ? form.value.groupId : undefined, |
|
|
groupIds: form.value.accountType === 'group' ? form.value.groupIds : undefined, |
|
|
expiresAt: form.value.expiresAt || undefined, |
|
|
proxy: proxyPayload |
|
|
} |
|
|
|
|
|
const currentPlatform = form.value.platform |
|
|
|
|
|
if (currentPlatform === 'claude') { |
|
|
|
|
|
data.claudeAiOauth = tokenInfo.claudeAiOauth || tokenInfo |
|
|
data.priority = form.value.priority || 50 |
|
|
data.autoStopOnWarning = form.value.autoStopOnWarning || false |
|
|
data.useUnifiedUserAgent = form.value.useUnifiedUserAgent || false |
|
|
data.useUnifiedClientId = form.value.useUnifiedClientId || false |
|
|
data.unifiedClientId = form.value.unifiedClientId || '' |
|
|
|
|
|
data.subscriptionInfo = { |
|
|
accountType: form.value.subscriptionType || 'claude_max', |
|
|
hasClaudeMax: form.value.subscriptionType === 'claude_max', |
|
|
hasClaudePro: form.value.subscriptionType === 'claude_pro', |
|
|
manuallySet: true |
|
|
} |
|
|
} else if (currentPlatform === 'gemini') { |
|
|
|
|
|
data.geminiOauth = tokenInfo.tokens || tokenInfo |
|
|
if (form.value.projectId) { |
|
|
data.projectId = form.value.projectId |
|
|
} |
|
|
|
|
|
data.priority = form.value.priority || 50 |
|
|
} else if (currentPlatform === 'openai') { |
|
|
data.openaiOauth = tokenInfo.tokens || tokenInfo |
|
|
data.accountInfo = tokenInfo.accountInfo |
|
|
data.priority = form.value.priority || 50 |
|
|
} else if (currentPlatform === 'droid') { |
|
|
const rawTokens = tokenInfo.tokens || tokenInfo || {} |
|
|
|
|
|
const normalizedTokens = { |
|
|
accessToken: rawTokens.accessToken || rawTokens.access_token || '', |
|
|
refreshToken: rawTokens.refreshToken || rawTokens.refresh_token || '', |
|
|
expiresAt: rawTokens.expiresAt || rawTokens.expires_at || '', |
|
|
expiresIn: rawTokens.expiresIn || rawTokens.expires_in || null, |
|
|
tokenType: rawTokens.tokenType || rawTokens.token_type || 'Bearer', |
|
|
organizationId: rawTokens.organizationId || rawTokens.organization_id || '', |
|
|
authenticationMethod: |
|
|
rawTokens.authenticationMethod || rawTokens.authentication_method || '' |
|
|
} |
|
|
|
|
|
if (!normalizedTokens.refreshToken) { |
|
|
loading.value = false |
|
|
showToast('授权成功但未返回 Refresh Token,请确认已授予离线访问权限后重试。', 'error') |
|
|
return |
|
|
} |
|
|
|
|
|
data.refreshToken = normalizedTokens.refreshToken |
|
|
data.accessToken = normalizedTokens.accessToken |
|
|
data.expiresAt = normalizedTokens.expiresAt |
|
|
if (normalizedTokens.expiresIn !== null && normalizedTokens.expiresIn !== undefined) { |
|
|
data.expiresIn = normalizedTokens.expiresIn |
|
|
} |
|
|
data.priority = form.value.priority || 50 |
|
|
data.endpointType = form.value.endpointType || 'anthropic' |
|
|
data.platform = 'droid' |
|
|
data.tokenType = normalizedTokens.tokenType |
|
|
data.authenticationMethod = normalizedTokens.authenticationMethod |
|
|
|
|
|
if (normalizedTokens.organizationId) { |
|
|
data.organizationId = normalizedTokens.organizationId |
|
|
} |
|
|
|
|
|
if (rawTokens.user) { |
|
|
const user = rawTokens.user |
|
|
const nameParts = [] |
|
|
if (typeof user.first_name === 'string' && user.first_name.trim()) { |
|
|
nameParts.push(user.first_name.trim()) |
|
|
} |
|
|
if (typeof user.last_name === 'string' && user.last_name.trim()) { |
|
|
nameParts.push(user.last_name.trim()) |
|
|
} |
|
|
const derivedName = |
|
|
nameParts.join(' ').trim() || |
|
|
(typeof user.name === 'string' ? user.name.trim() : '') || |
|
|
(typeof user.display_name === 'string' ? user.display_name.trim() : '') |
|
|
|
|
|
if (typeof user.email === 'string' && user.email.trim()) { |
|
|
data.ownerEmail = user.email.trim() |
|
|
} |
|
|
if (derivedName) { |
|
|
data.ownerName = derivedName |
|
|
data.ownerDisplayName = derivedName |
|
|
} else if (data.ownerEmail) { |
|
|
data.ownerName = data.ownerName || data.ownerEmail |
|
|
data.ownerDisplayName = data.ownerDisplayName || data.ownerEmail |
|
|
} |
|
|
if (typeof user.id === 'string' && user.id.trim()) { |
|
|
data.userId = user.id.trim() |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
let result |
|
|
if (currentPlatform === 'claude') { |
|
|
result = await accountsStore.createClaudeAccount(data) |
|
|
} else if (currentPlatform === 'gemini') { |
|
|
result = await accountsStore.createGeminiAccount(data) |
|
|
} else if (currentPlatform === 'openai') { |
|
|
result = await accountsStore.createOpenAIAccount(data) |
|
|
} else if (currentPlatform === 'droid') { |
|
|
result = await accountsStore.createDroidAccount(data) |
|
|
} else { |
|
|
result = await accountsStore.createGeminiAccount(data) |
|
|
} |
|
|
|
|
|
emit('success', result) |
|
|
} catch (error) { |
|
|
|
|
|
const errorMessage = error.response?.data?.error || error.message || '账户创建失败' |
|
|
const suggestion = error.response?.data?.suggestion || '' |
|
|
const errorDetails = error.response?.data?.errorDetails || null |
|
|
|
|
|
|
|
|
let fullMessage = errorMessage |
|
|
if (suggestion) { |
|
|
fullMessage += `\n${suggestion}` |
|
|
} |
|
|
|
|
|
|
|
|
if (errorDetails && errorDetails.error_description) { |
|
|
fullMessage += `\n详细信息: ${errorDetails.error_description}` |
|
|
} else if (errorDetails && errorDetails.error && errorDetails.error.message) { |
|
|
|
|
|
fullMessage += `\n详细信息: ${errorDetails.error.message}` |
|
|
} |
|
|
|
|
|
showToast(fullMessage, 'error', '', 8000) |
|
|
|
|
|
|
|
|
} finally { |
|
|
loading.value = false |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const createAccount = async () => { |
|
|
|
|
|
errors.value.name = '' |
|
|
errors.value.accessToken = '' |
|
|
errors.value.refreshToken = '' |
|
|
errors.value.apiUrl = '' |
|
|
errors.value.apiKey = '' |
|
|
errors.value.apiKeys = '' |
|
|
|
|
|
let hasError = false |
|
|
|
|
|
if (!form.value.name || form.value.name.trim() === '') { |
|
|
errors.value.name = '请填写账户名称' |
|
|
hasError = true |
|
|
} |
|
|
|
|
|
|
|
|
if (form.value.platform === 'claude-console') { |
|
|
if (!form.value.apiUrl || form.value.apiUrl.trim() === '') { |
|
|
errors.value.apiUrl = '请填写 API URL' |
|
|
hasError = true |
|
|
} |
|
|
if (!form.value.apiKey || form.value.apiKey.trim() === '') { |
|
|
errors.value.apiKey = '请填写 API Key' |
|
|
hasError = true |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
if (form.value.platform === 'ccr') { |
|
|
if (!form.value.apiUrl || form.value.apiUrl.trim() === '') { |
|
|
errors.value.apiUrl = '请填写 API URL' |
|
|
hasError = true |
|
|
} |
|
|
if (!form.value.apiKey || form.value.apiKey.trim() === '') { |
|
|
errors.value.apiKey = '请填写 API Key' |
|
|
hasError = true |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
if (form.value.platform === 'openai-responses') { |
|
|
if (!form.value.baseApi || form.value.baseApi.trim() === '') { |
|
|
errors.value.baseApi = '请填写 API 基础地址' |
|
|
hasError = true |
|
|
} |
|
|
if (!form.value.apiKey || form.value.apiKey.trim() === '') { |
|
|
errors.value.apiKey = '请填写 API 密钥' |
|
|
hasError = true |
|
|
} |
|
|
} else if (form.value.platform === 'bedrock') { |
|
|
|
|
|
if (!form.value.accessKeyId || form.value.accessKeyId.trim() === '') { |
|
|
errors.value.accessKeyId = '请填写 AWS 访问密钥 ID' |
|
|
hasError = true |
|
|
} |
|
|
if (!form.value.secretAccessKey || form.value.secretAccessKey.trim() === '') { |
|
|
errors.value.secretAccessKey = '请填写 AWS 秘密访问密钥' |
|
|
hasError = true |
|
|
} |
|
|
if (!form.value.region || form.value.region.trim() === '') { |
|
|
errors.value.region = '请选择 AWS 区域' |
|
|
hasError = true |
|
|
} |
|
|
} else if (form.value.platform === 'azure_openai') { |
|
|
|
|
|
if (!form.value.azureEndpoint || form.value.azureEndpoint.trim() === '') { |
|
|
errors.value.azureEndpoint = '请填写 Azure Endpoint' |
|
|
hasError = true |
|
|
} |
|
|
if (!form.value.deploymentName || form.value.deploymentName.trim() === '') { |
|
|
errors.value.deploymentName = '请填写部署名称' |
|
|
hasError = true |
|
|
} |
|
|
if (!form.value.apiKey || form.value.apiKey.trim() === '') { |
|
|
errors.value.apiKey = '请填写 API Key' |
|
|
hasError = true |
|
|
} |
|
|
} else if (form.value.addType === 'manual') { |
|
|
|
|
|
if (form.value.platform === 'openai') { |
|
|
|
|
|
if (!form.value.refreshToken || form.value.refreshToken.trim() === '') { |
|
|
errors.value.refreshToken = '请填写 Refresh Token' |
|
|
hasError = true |
|
|
} |
|
|
|
|
|
} else if (form.value.platform === 'gemini') { |
|
|
|
|
|
if (!form.value.accessToken || form.value.accessToken.trim() === '') { |
|
|
errors.value.accessToken = '请填写 Access Token' |
|
|
hasError = true |
|
|
} |
|
|
} else if (form.value.platform === 'droid') { |
|
|
if (!form.value.accessToken || form.value.accessToken.trim() === '') { |
|
|
errors.value.accessToken = '请填写 Access Token' |
|
|
hasError = true |
|
|
} |
|
|
if (!form.value.refreshToken || form.value.refreshToken.trim() === '') { |
|
|
errors.value.refreshToken = '请填写 Refresh Token' |
|
|
hasError = true |
|
|
} |
|
|
} else if (form.value.platform === 'claude') { |
|
|
|
|
|
if (!form.value.accessToken || form.value.accessToken.trim() === '') { |
|
|
errors.value.accessToken = '请填写 Access Token' |
|
|
hasError = true |
|
|
} |
|
|
} |
|
|
|
|
|
} else if (form.value.addType === 'apikey') { |
|
|
const apiKeys = parseApiKeysInput(form.value.apiKeysInput) |
|
|
if (apiKeys.length === 0) { |
|
|
errors.value.apiKeys = '请至少填写一个 API Key' |
|
|
hasError = true |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
if ( |
|
|
form.value.accountType === 'group' && |
|
|
(!form.value.groupIds || form.value.groupIds.length === 0) |
|
|
) { |
|
|
showToast('请选择一个分组', 'error') |
|
|
hasError = true |
|
|
} |
|
|
|
|
|
|
|
|
if (form.value.accountType === 'group') { |
|
|
if (form.value.groupIds && form.value.groupIds.length > 0) { |
|
|
form.value.groupId = form.value.groupIds[0] |
|
|
} else { |
|
|
form.value.groupId = '' |
|
|
} |
|
|
} |
|
|
|
|
|
if (hasError) { |
|
|
return |
|
|
} |
|
|
|
|
|
loading.value = true |
|
|
try { |
|
|
const proxyPayload = buildProxyPayload(form.value.proxy) |
|
|
|
|
|
const data = { |
|
|
name: form.value.name, |
|
|
description: form.value.description, |
|
|
accountType: form.value.accountType, |
|
|
groupId: form.value.accountType === 'group' ? form.value.groupId : undefined, |
|
|
groupIds: form.value.accountType === 'group' ? form.value.groupIds : undefined, |
|
|
expiresAt: form.value.expiresAt || undefined, |
|
|
proxy: proxyPayload |
|
|
} |
|
|
|
|
|
if (form.value.platform === 'claude') { |
|
|
|
|
|
const expiresInMs = form.value.refreshToken |
|
|
? 10 * 60 * 1000 |
|
|
: 365 * 24 * 60 * 60 * 1000 |
|
|
|
|
|
|
|
|
if (form.value.useUnifiedClientId && !form.value.unifiedClientId) { |
|
|
form.value.unifiedClientId = generateClientId() |
|
|
} |
|
|
|
|
|
data.claudeAiOauth = { |
|
|
accessToken: form.value.accessToken, |
|
|
refreshToken: form.value.refreshToken || '', |
|
|
expiresAt: Date.now() + expiresInMs, |
|
|
scopes: [] |
|
|
} |
|
|
data.priority = form.value.priority || 50 |
|
|
data.autoStopOnWarning = form.value.autoStopOnWarning || false |
|
|
data.useUnifiedUserAgent = form.value.useUnifiedUserAgent || false |
|
|
data.useUnifiedClientId = form.value.useUnifiedClientId || false |
|
|
data.unifiedClientId = form.value.unifiedClientId || '' |
|
|
|
|
|
data.subscriptionInfo = { |
|
|
accountType: form.value.subscriptionType || 'claude_max', |
|
|
hasClaudeMax: form.value.subscriptionType === 'claude_max', |
|
|
hasClaudePro: form.value.subscriptionType === 'claude_pro', |
|
|
manuallySet: true |
|
|
} |
|
|
} else if (form.value.platform === 'gemini') { |
|
|
|
|
|
const expiresInMs = form.value.refreshToken |
|
|
? 10 * 60 * 1000 |
|
|
: 365 * 24 * 60 * 60 * 1000 |
|
|
|
|
|
data.geminiOauth = { |
|
|
access_token: form.value.accessToken, |
|
|
refresh_token: form.value.refreshToken || '', |
|
|
scope: 'https://www.googleapis.com/auth/cloud-platform', |
|
|
token_type: 'Bearer', |
|
|
expiry_date: Date.now() + expiresInMs |
|
|
} |
|
|
|
|
|
if (form.value.projectId) { |
|
|
data.projectId = form.value.projectId |
|
|
} |
|
|
|
|
|
|
|
|
data.priority = form.value.priority || 50 |
|
|
} else if (form.value.platform === 'openai') { |
|
|
|
|
|
const expiresInMs = form.value.refreshToken |
|
|
? 10 * 60 * 1000 |
|
|
: 365 * 24 * 60 * 60 * 1000 |
|
|
|
|
|
data.openaiOauth = { |
|
|
idToken: '', |
|
|
accessToken: form.value.accessToken || '', |
|
|
refreshToken: form.value.refreshToken, |
|
|
expires_in: Math.floor(expiresInMs / 1000) |
|
|
} |
|
|
|
|
|
|
|
|
data.accountInfo = { |
|
|
accountId: '', |
|
|
chatgptUserId: '', |
|
|
organizationId: '', |
|
|
organizationRole: '', |
|
|
organizationTitle: '', |
|
|
planType: '', |
|
|
email: '', |
|
|
emailVerified: false |
|
|
} |
|
|
|
|
|
|
|
|
data.needsImmediateRefresh = true |
|
|
data.requireRefreshSuccess = true |
|
|
data.priority = form.value.priority || 50 |
|
|
} else if (form.value.platform === 'droid') { |
|
|
data.priority = form.value.priority || 50 |
|
|
data.endpointType = form.value.endpointType || 'anthropic' |
|
|
data.platform = 'droid' |
|
|
|
|
|
if (form.value.addType === 'apikey') { |
|
|
const apiKeys = parseApiKeysInput(form.value.apiKeysInput) |
|
|
data.apiKeys = apiKeys |
|
|
data.authenticationMethod = 'api_key' |
|
|
data.isActive = true |
|
|
data.schedulable = true |
|
|
} else { |
|
|
const accessToken = form.value.accessToken?.trim() || '' |
|
|
const refreshToken = form.value.refreshToken?.trim() || '' |
|
|
const expiresAt = new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString() |
|
|
|
|
|
data.accessToken = accessToken |
|
|
data.refreshToken = refreshToken |
|
|
data.expiresAt = expiresAt |
|
|
data.expiresIn = 8 * 60 * 60 |
|
|
data.tokenType = 'Bearer' |
|
|
data.authenticationMethod = 'manual' |
|
|
} |
|
|
} else if (form.value.platform === 'claude-console' || form.value.platform === 'ccr') { |
|
|
|
|
|
data.apiUrl = form.value.apiUrl |
|
|
data.apiKey = form.value.apiKey |
|
|
data.priority = form.value.priority || 50 |
|
|
data.supportedModels = convertMappingsToObject() || {} |
|
|
data.userAgent = form.value.userAgent || null |
|
|
|
|
|
data.rateLimitDuration = form.value.enableRateLimit ? form.value.rateLimitDuration || 60 : 0 |
|
|
|
|
|
data.dailyQuota = form.value.dailyQuota || 0 |
|
|
data.quotaResetTime = form.value.quotaResetTime || '00:00' |
|
|
} else if (form.value.platform === 'openai-responses') { |
|
|
|
|
|
data.baseApi = form.value.baseApi |
|
|
data.apiKey = form.value.apiKey |
|
|
data.userAgent = form.value.userAgent || '' |
|
|
data.priority = form.value.priority || 50 |
|
|
data.rateLimitDuration = 60 |
|
|
data.dailyQuota = form.value.dailyQuota || 0 |
|
|
data.quotaResetTime = form.value.quotaResetTime || '00:00' |
|
|
} else if (form.value.platform === 'bedrock') { |
|
|
|
|
|
data.awsCredentials = { |
|
|
accessKeyId: form.value.accessKeyId, |
|
|
secretAccessKey: form.value.secretAccessKey, |
|
|
sessionToken: form.value.sessionToken || null |
|
|
} |
|
|
data.region = form.value.region |
|
|
data.defaultModel = form.value.defaultModel || null |
|
|
data.smallFastModel = form.value.smallFastModel || null |
|
|
data.priority = form.value.priority || 50 |
|
|
|
|
|
data.rateLimitDuration = form.value.enableRateLimit ? form.value.rateLimitDuration || 60 : 0 |
|
|
} else if (form.value.platform === 'azure_openai') { |
|
|
|
|
|
data.azureEndpoint = form.value.azureEndpoint |
|
|
data.apiKey = form.value.apiKey |
|
|
data.apiVersion = form.value.apiVersion || '2024-02-01' |
|
|
data.deploymentName = form.value.deploymentName |
|
|
data.supportedModels = Array.isArray(form.value.supportedModels) |
|
|
? form.value.supportedModels |
|
|
: [] |
|
|
data.priority = form.value.priority || 50 |
|
|
data.isActive = form.value.isActive !== false |
|
|
data.schedulable = form.value.schedulable !== false |
|
|
} |
|
|
|
|
|
let result |
|
|
if (form.value.platform === 'claude') { |
|
|
result = await accountsStore.createClaudeAccount(data) |
|
|
} else if (form.value.platform === 'claude-console' || form.value.platform === 'ccr') { |
|
|
|
|
|
result = await accountsStore.createClaudeConsoleAccount(data) |
|
|
} else if (form.value.platform === 'droid') { |
|
|
result = await accountsStore.createDroidAccount(data) |
|
|
} else if (form.value.platform === 'openai-responses') { |
|
|
result = await accountsStore.createOpenAIResponsesAccount(data) |
|
|
} else if (form.value.platform === 'bedrock') { |
|
|
result = await accountsStore.createBedrockAccount(data) |
|
|
} else if (form.value.platform === 'openai') { |
|
|
result = await accountsStore.createOpenAIAccount(data) |
|
|
} else if (form.value.platform === 'azure_openai') { |
|
|
result = await accountsStore.createAzureOpenAIAccount(data) |
|
|
} else if (form.value.platform === 'gemini') { |
|
|
result = await accountsStore.createGeminiAccount(data) |
|
|
} else { |
|
|
throw new Error(`不支持的平台: ${form.value.platform}`) |
|
|
} |
|
|
|
|
|
emit('success', result) |
|
|
} catch (error) { |
|
|
|
|
|
const errorMessage = error.response?.data?.error || error.message || '账户创建失败' |
|
|
const suggestion = error.response?.data?.suggestion || '' |
|
|
const errorDetails = error.response?.data?.errorDetails || null |
|
|
|
|
|
|
|
|
let fullMessage = errorMessage |
|
|
if (suggestion) { |
|
|
fullMessage += `\n${suggestion}` |
|
|
} |
|
|
|
|
|
|
|
|
if (errorDetails && errorDetails.error_description) { |
|
|
fullMessage += `\n详细信息: ${errorDetails.error_description}` |
|
|
} else if (errorDetails && errorDetails.error && errorDetails.error.message) { |
|
|
|
|
|
fullMessage += `\n详细信息: ${errorDetails.error.message}` |
|
|
} |
|
|
|
|
|
showToast(fullMessage, 'error', '', 8000) |
|
|
|
|
|
|
|
|
} finally { |
|
|
loading.value = false |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const updateAccount = async () => { |
|
|
|
|
|
errors.value.name = '' |
|
|
errors.value.apiKeys = '' |
|
|
|
|
|
|
|
|
if (!form.value.name || form.value.name.trim() === '') { |
|
|
errors.value.name = '请填写账户名称' |
|
|
return |
|
|
} |
|
|
|
|
|
|
|
|
if ( |
|
|
form.value.accountType === 'group' && |
|
|
(!form.value.groupIds || form.value.groupIds.length === 0) |
|
|
) { |
|
|
showToast('请选择一个分组', 'error') |
|
|
return |
|
|
} |
|
|
|
|
|
|
|
|
if (form.value.accountType === 'group') { |
|
|
if (form.value.groupIds && form.value.groupIds.length > 0) { |
|
|
form.value.groupId = form.value.groupIds[0] |
|
|
} else { |
|
|
form.value.groupId = '' |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
if (form.value.platform === 'gemini') { |
|
|
if (!form.value.projectId || form.value.projectId.trim() === '') { |
|
|
|
|
|
const confirmed = await showConfirm( |
|
|
'项目 ID 未填写', |
|
|
'您尚未填写项目 ID。\n\n如果您的Google账号绑定了Google Cloud或被识别为Workspace账号,需要提供项目 ID。\n如果您使用的是普通个人账号,可以继续不填写。', |
|
|
'继续保存', |
|
|
'返回填写' |
|
|
) |
|
|
if (!confirmed) { |
|
|
return |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
loading.value = true |
|
|
try { |
|
|
const proxyPayload = buildProxyPayload(form.value.proxy) |
|
|
|
|
|
const data = { |
|
|
name: form.value.name, |
|
|
description: form.value.description, |
|
|
accountType: form.value.accountType, |
|
|
groupId: form.value.accountType === 'group' ? form.value.groupId : undefined, |
|
|
groupIds: form.value.accountType === 'group' ? form.value.groupIds : undefined, |
|
|
expiresAt: form.value.expiresAt || undefined, |
|
|
proxy: proxyPayload |
|
|
} |
|
|
|
|
|
|
|
|
if (form.value.accessToken || form.value.refreshToken) { |
|
|
const trimmedAccessToken = form.value.accessToken?.trim() || '' |
|
|
const trimmedRefreshToken = form.value.refreshToken?.trim() || '' |
|
|
|
|
|
if (props.account.platform === 'claude') { |
|
|
|
|
|
const expiresInMs = form.value.refreshToken |
|
|
? 10 * 60 * 1000 |
|
|
: 365 * 24 * 60 * 60 * 1000 |
|
|
|
|
|
data.claudeAiOauth = { |
|
|
accessToken: trimmedAccessToken || '', |
|
|
refreshToken: trimmedRefreshToken || '', |
|
|
expiresAt: Date.now() + expiresInMs, |
|
|
scopes: props.account.scopes || [] |
|
|
} |
|
|
} else if (props.account.platform === 'gemini') { |
|
|
|
|
|
const expiresInMs = form.value.refreshToken |
|
|
? 10 * 60 * 1000 |
|
|
: 365 * 24 * 60 * 60 * 1000 |
|
|
|
|
|
data.geminiOauth = { |
|
|
access_token: trimmedAccessToken || '', |
|
|
refresh_token: trimmedRefreshToken || '', |
|
|
scope: 'https://www.googleapis.com/auth/cloud-platform', |
|
|
token_type: 'Bearer', |
|
|
expiry_date: Date.now() + expiresInMs |
|
|
} |
|
|
} else if (props.account.platform === 'openai') { |
|
|
|
|
|
const expiresInMs = form.value.refreshToken |
|
|
? 10 * 60 * 1000 |
|
|
: 365 * 24 * 60 * 60 * 1000 |
|
|
|
|
|
data.openaiOauth = { |
|
|
idToken: '', |
|
|
accessToken: trimmedAccessToken || '', |
|
|
refreshToken: trimmedRefreshToken || '', |
|
|
expires_in: Math.floor(expiresInMs / 1000) |
|
|
} |
|
|
|
|
|
|
|
|
if (trimmedRefreshToken && trimmedRefreshToken !== props.account.refreshToken) { |
|
|
data.needsImmediateRefresh = true |
|
|
data.requireRefreshSuccess = true |
|
|
} |
|
|
} else if (props.account.platform === 'droid') { |
|
|
if (trimmedAccessToken) { |
|
|
data.accessToken = trimmedAccessToken |
|
|
} |
|
|
if (trimmedRefreshToken) { |
|
|
data.refreshToken = trimmedRefreshToken |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
if (props.account.platform === 'droid') { |
|
|
const trimmedApiKeysInput = form.value.apiKeysInput?.trim() || '' |
|
|
const apiKeyUpdateMode = form.value.apiKeyUpdateMode || 'append' |
|
|
|
|
|
if (apiKeyUpdateMode === 'delete') { |
|
|
if (!trimmedApiKeysInput) { |
|
|
errors.value.apiKeys = '请填写需要删除的 API Key' |
|
|
loading.value = false |
|
|
return |
|
|
} |
|
|
|
|
|
const removeApiKeys = parseApiKeysInput(trimmedApiKeysInput) |
|
|
if (removeApiKeys.length === 0) { |
|
|
errors.value.apiKeys = '请填写需要删除的 API Key' |
|
|
loading.value = false |
|
|
return |
|
|
} |
|
|
|
|
|
data.removeApiKeys = removeApiKeys |
|
|
data.apiKeyUpdateMode = 'delete' |
|
|
} else { |
|
|
if (trimmedApiKeysInput) { |
|
|
const apiKeys = parseApiKeysInput(trimmedApiKeysInput) |
|
|
if (apiKeys.length === 0) { |
|
|
errors.value.apiKeys = '请至少填写一个 API Key' |
|
|
loading.value = false |
|
|
return |
|
|
} |
|
|
data.apiKeys = apiKeys |
|
|
} else if (apiKeyUpdateMode === 'replace') { |
|
|
data.apiKeys = [] |
|
|
} |
|
|
|
|
|
if (apiKeyUpdateMode !== 'append' || trimmedApiKeysInput) { |
|
|
data.apiKeyUpdateMode = apiKeyUpdateMode |
|
|
} |
|
|
} |
|
|
|
|
|
if (isEditingDroidApiKey.value) { |
|
|
data.authenticationMethod = 'api_key' |
|
|
} |
|
|
} |
|
|
|
|
|
if (props.account.platform === 'gemini') { |
|
|
data.projectId = form.value.projectId || '' |
|
|
} |
|
|
|
|
|
if (props.account.platform === 'droid') { |
|
|
data.priority = form.value.priority || 50 |
|
|
data.endpointType = form.value.endpointType || 'anthropic' |
|
|
} |
|
|
|
|
|
|
|
|
if (props.account.platform === 'claude') { |
|
|
|
|
|
if (form.value.useUnifiedClientId && !form.value.unifiedClientId) { |
|
|
form.value.unifiedClientId = generateClientId() |
|
|
} |
|
|
|
|
|
data.priority = form.value.priority || 50 |
|
|
data.autoStopOnWarning = form.value.autoStopOnWarning || false |
|
|
data.useUnifiedUserAgent = form.value.useUnifiedUserAgent || false |
|
|
data.useUnifiedClientId = form.value.useUnifiedClientId || false |
|
|
data.unifiedClientId = form.value.unifiedClientId || '' |
|
|
|
|
|
data.subscriptionInfo = { |
|
|
accountType: form.value.subscriptionType || 'claude_max', |
|
|
hasClaudeMax: form.value.subscriptionType === 'claude_max', |
|
|
hasClaudePro: form.value.subscriptionType === 'claude_pro', |
|
|
manuallySet: true |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
if (props.account.platform === 'openai') { |
|
|
data.priority = form.value.priority || 50 |
|
|
} |
|
|
|
|
|
|
|
|
if (props.account.platform === 'gemini') { |
|
|
data.priority = form.value.priority || 50 |
|
|
} |
|
|
|
|
|
|
|
|
if (props.account.platform === 'claude-console') { |
|
|
data.apiUrl = form.value.apiUrl |
|
|
if (form.value.apiKey) { |
|
|
data.apiKey = form.value.apiKey |
|
|
} |
|
|
data.priority = form.value.priority || 50 |
|
|
data.supportedModels = convertMappingsToObject() || {} |
|
|
data.userAgent = form.value.userAgent || null |
|
|
|
|
|
data.rateLimitDuration = form.value.enableRateLimit ? form.value.rateLimitDuration || 60 : 0 |
|
|
|
|
|
data.dailyQuota = form.value.dailyQuota || 0 |
|
|
data.quotaResetTime = form.value.quotaResetTime || '00:00' |
|
|
} |
|
|
|
|
|
|
|
|
if (props.account.platform === 'openai-responses') { |
|
|
data.baseApi = form.value.baseApi |
|
|
if (form.value.apiKey) { |
|
|
data.apiKey = form.value.apiKey |
|
|
} |
|
|
data.userAgent = form.value.userAgent || '' |
|
|
data.priority = form.value.priority || 50 |
|
|
|
|
|
data.dailyQuota = form.value.dailyQuota || 0 |
|
|
data.quotaResetTime = form.value.quotaResetTime || '00:00' |
|
|
} |
|
|
|
|
|
|
|
|
if (props.account.platform === 'bedrock') { |
|
|
|
|
|
if (form.value.accessKeyId || form.value.secretAccessKey || form.value.sessionToken) { |
|
|
data.awsCredentials = {} |
|
|
if (form.value.accessKeyId) { |
|
|
data.awsCredentials.accessKeyId = form.value.accessKeyId |
|
|
} |
|
|
if (form.value.secretAccessKey) { |
|
|
data.awsCredentials.secretAccessKey = form.value.secretAccessKey |
|
|
} |
|
|
if (form.value.sessionToken !== undefined) { |
|
|
data.awsCredentials.sessionToken = form.value.sessionToken || null |
|
|
} |
|
|
} |
|
|
if (form.value.region) { |
|
|
data.region = form.value.region |
|
|
} |
|
|
|
|
|
data.defaultModel = form.value.defaultModel || null |
|
|
data.smallFastModel = form.value.smallFastModel || null |
|
|
data.priority = form.value.priority || 50 |
|
|
|
|
|
data.rateLimitDuration = form.value.enableRateLimit ? form.value.rateLimitDuration || 60 : 0 |
|
|
} |
|
|
|
|
|
|
|
|
if (props.account.platform === 'azure_openai') { |
|
|
data.azureEndpoint = form.value.azureEndpoint |
|
|
data.apiVersion = form.value.apiVersion || '2024-02-01' |
|
|
data.deploymentName = form.value.deploymentName |
|
|
data.supportedModels = Array.isArray(form.value.supportedModels) |
|
|
? form.value.supportedModels |
|
|
: [] |
|
|
data.priority = form.value.priority || 50 |
|
|
|
|
|
if (form.value.apiKey && form.value.apiKey.trim()) { |
|
|
data.apiKey = form.value.apiKey |
|
|
} |
|
|
} |
|
|
|
|
|
if (props.account.platform === 'claude') { |
|
|
await accountsStore.updateClaudeAccount(props.account.id, data) |
|
|
} else if (props.account.platform === 'claude-console') { |
|
|
await accountsStore.updateClaudeConsoleAccount(props.account.id, data) |
|
|
} else if (props.account.platform === 'openai-responses') { |
|
|
await accountsStore.updateOpenAIResponsesAccount(props.account.id, data) |
|
|
} else if (props.account.platform === 'bedrock') { |
|
|
await accountsStore.updateBedrockAccount(props.account.id, data) |
|
|
} else if (props.account.platform === 'openai') { |
|
|
await accountsStore.updateOpenAIAccount(props.account.id, data) |
|
|
} else if (props.account.platform === 'azure_openai') { |
|
|
await accountsStore.updateAzureOpenAIAccount(props.account.id, data) |
|
|
} else if (props.account.platform === 'gemini') { |
|
|
await accountsStore.updateGeminiAccount(props.account.id, data) |
|
|
} else if (props.account.platform === 'droid') { |
|
|
await accountsStore.updateDroidAccount(props.account.id, data) |
|
|
} else { |
|
|
throw new Error(`不支持的平台: ${props.account.platform}`) |
|
|
} |
|
|
|
|
|
emit('success') |
|
|
} catch (error) { |
|
|
|
|
|
const errorMessage = error.response?.data?.error || error.message || '账户更新失败' |
|
|
const suggestion = error.response?.data?.suggestion || '' |
|
|
const errorDetails = error.response?.data?.errorDetails || null |
|
|
|
|
|
|
|
|
let fullMessage = errorMessage |
|
|
if (suggestion) { |
|
|
fullMessage += `\n${suggestion}` |
|
|
} |
|
|
|
|
|
|
|
|
if (errorDetails && errorDetails.error_description) { |
|
|
fullMessage += `\n详细信息: ${errorDetails.error_description}` |
|
|
} else if (errorDetails && errorDetails.error && errorDetails.error.message) { |
|
|
|
|
|
fullMessage += `\n详细信息: ${errorDetails.error.message}` |
|
|
} |
|
|
|
|
|
showToast(fullMessage, 'error', '', 8000) |
|
|
|
|
|
|
|
|
} finally { |
|
|
loading.value = false |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
watch( |
|
|
() => form.value.name, |
|
|
() => { |
|
|
if (errors.value.name && form.value.name?.trim()) { |
|
|
errors.value.name = '' |
|
|
} |
|
|
} |
|
|
) |
|
|
|
|
|
|
|
|
watch( |
|
|
() => form.value.accessToken, |
|
|
() => { |
|
|
if (errors.value.accessToken && form.value.accessToken?.trim()) { |
|
|
errors.value.accessToken = '' |
|
|
} |
|
|
} |
|
|
) |
|
|
|
|
|
|
|
|
watch( |
|
|
() => form.value.refreshToken, |
|
|
() => { |
|
|
if (errors.value.refreshToken && form.value.refreshToken?.trim()) { |
|
|
errors.value.refreshToken = '' |
|
|
} |
|
|
} |
|
|
) |
|
|
|
|
|
|
|
|
watch( |
|
|
() => form.value.apiUrl, |
|
|
() => { |
|
|
if (errors.value.apiUrl && form.value.apiUrl?.trim()) { |
|
|
errors.value.apiUrl = '' |
|
|
} |
|
|
} |
|
|
) |
|
|
|
|
|
|
|
|
watch( |
|
|
() => form.value.apiKey, |
|
|
() => { |
|
|
if (errors.value.apiKey && form.value.apiKey?.trim()) { |
|
|
errors.value.apiKey = '' |
|
|
} |
|
|
} |
|
|
) |
|
|
|
|
|
|
|
|
watch( |
|
|
() => form.value.azureEndpoint, |
|
|
() => { |
|
|
if (errors.value.azureEndpoint && form.value.azureEndpoint?.trim()) { |
|
|
errors.value.azureEndpoint = '' |
|
|
} |
|
|
} |
|
|
) |
|
|
|
|
|
|
|
|
watch( |
|
|
() => form.value.deploymentName, |
|
|
() => { |
|
|
if (errors.value.deploymentName && form.value.deploymentName?.trim()) { |
|
|
errors.value.deploymentName = '' |
|
|
} |
|
|
} |
|
|
) |
|
|
|
|
|
|
|
|
const groups = ref([]) |
|
|
const loadingGroups = ref(false) |
|
|
const showGroupManagement = ref(false) |
|
|
|
|
|
|
|
|
const filteredGroups = computed(() => { |
|
|
let platformFilter = form.value.platform |
|
|
|
|
|
if (form.value.platform === 'claude-console' || form.value.platform === 'ccr') { |
|
|
platformFilter = 'claude' |
|
|
} |
|
|
|
|
|
else if (form.value.platform === 'openai-responses') { |
|
|
platformFilter = 'openai' |
|
|
} |
|
|
return groups.value.filter((g) => g.platform === platformFilter) |
|
|
}) |
|
|
|
|
|
|
|
|
const loadGroups = async () => { |
|
|
loadingGroups.value = true |
|
|
try { |
|
|
const response = await apiClient.get('/admin/account-groups') |
|
|
groups.value = response.data || [] |
|
|
} catch (error) { |
|
|
showToast('加载分组列表失败', 'error') |
|
|
groups.value = [] |
|
|
} finally { |
|
|
loadingGroups.value = false |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const refreshGroups = async () => { |
|
|
await loadGroups() |
|
|
showToast('分组列表已刷新', 'success') |
|
|
} |
|
|
|
|
|
|
|
|
const handleNewGroup = () => { |
|
|
showGroupManagement.value = true |
|
|
} |
|
|
|
|
|
|
|
|
const handleGroupRefresh = async () => { |
|
|
await loadGroups() |
|
|
} |
|
|
|
|
|
|
|
|
const handleApiKeyRefresh = async () => { |
|
|
|
|
|
if (!props.account?.id) { |
|
|
return |
|
|
} |
|
|
|
|
|
const refreshers = [ |
|
|
typeof accountsStore.fetchDroidAccounts === 'function' |
|
|
? accountsStore.fetchDroidAccounts |
|
|
: null, |
|
|
typeof accountsStore.fetchAllAccounts === 'function' ? accountsStore.fetchAllAccounts : null |
|
|
].filter(Boolean) |
|
|
|
|
|
for (const refresher of refreshers) { |
|
|
try { |
|
|
await refresher() |
|
|
return |
|
|
} catch (error) { |
|
|
console.error('刷新账户列表失败:', error) |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
watch( |
|
|
() => form.value.platform, |
|
|
(newPlatform) => { |
|
|
|
|
|
if ( |
|
|
newPlatform === 'claude-console' || |
|
|
newPlatform === 'ccr' || |
|
|
newPlatform === 'bedrock' || |
|
|
newPlatform === 'openai-responses' |
|
|
) { |
|
|
form.value.addType = 'manual' |
|
|
} else if (newPlatform === 'claude') { |
|
|
|
|
|
form.value.addType = 'oauth' |
|
|
} else if (newPlatform === 'gemini') { |
|
|
|
|
|
form.value.addType = 'oauth' |
|
|
} else if (newPlatform === 'openai') { |
|
|
|
|
|
form.value.addType = 'oauth' |
|
|
} |
|
|
|
|
|
|
|
|
if (form.value.accountType === 'group') { |
|
|
form.value.groupId = '' |
|
|
form.value.groupIds = [] |
|
|
} |
|
|
} |
|
|
) |
|
|
|
|
|
|
|
|
watch( |
|
|
() => form.value.groupIds, |
|
|
(newGroupIds) => { |
|
|
if (form.value.accountType === 'group') { |
|
|
if (newGroupIds && newGroupIds.length > 0) { |
|
|
|
|
|
form.value.groupId = newGroupIds[0] |
|
|
} else { |
|
|
|
|
|
form.value.groupId = '' |
|
|
} |
|
|
} |
|
|
}, |
|
|
{ deep: true } |
|
|
) |
|
|
|
|
|
|
|
|
watch( |
|
|
() => form.value.addType, |
|
|
(newType, oldType) => { |
|
|
if (newType === oldType) { |
|
|
return |
|
|
} |
|
|
|
|
|
if (newType === 'apikey') { |
|
|
|
|
|
form.value.accessToken = '' |
|
|
form.value.refreshToken = '' |
|
|
errors.value.accessToken = '' |
|
|
errors.value.refreshToken = '' |
|
|
form.value.authenticationMethod = 'api_key' |
|
|
form.value.apiKeyUpdateMode = 'append' |
|
|
} else if (oldType === 'apikey') { |
|
|
|
|
|
form.value.apiKeysInput = '' |
|
|
form.value.apiKeyUpdateMode = 'append' |
|
|
errors.value.apiKeys = '' |
|
|
if (!isEdit.value) { |
|
|
form.value.authenticationMethod = '' |
|
|
} |
|
|
} |
|
|
} |
|
|
) |
|
|
|
|
|
|
|
|
watch( |
|
|
() => form.value.apiKeyUpdateMode, |
|
|
(newMode, oldMode) => { |
|
|
if (newMode === oldMode) { |
|
|
return |
|
|
} |
|
|
|
|
|
if (errors.value.apiKeys) { |
|
|
errors.value.apiKeys = '' |
|
|
} |
|
|
} |
|
|
) |
|
|
|
|
|
|
|
|
watch( |
|
|
() => form.value.apiKeysInput, |
|
|
(newValue) => { |
|
|
if (!errors.value.apiKeys) { |
|
|
return |
|
|
} |
|
|
|
|
|
const parsed = parseApiKeysInput(newValue) |
|
|
const mode = form.value.apiKeyUpdateMode |
|
|
|
|
|
if (mode === 'append' && parsed.length > 0) { |
|
|
errors.value.apiKeys = '' |
|
|
return |
|
|
} |
|
|
|
|
|
if (mode === 'replace') { |
|
|
if (parsed.length > 0 || !newValue || newValue.trim() === '') { |
|
|
errors.value.apiKeys = '' |
|
|
} |
|
|
return |
|
|
} |
|
|
|
|
|
if (mode === 'delete' && parsed.length > 0) { |
|
|
errors.value.apiKeys = '' |
|
|
} |
|
|
} |
|
|
) |
|
|
|
|
|
|
|
|
watch(setupTokenAuthCode, (newValue) => { |
|
|
if (!newValue || typeof newValue !== 'string') return |
|
|
|
|
|
const trimmedValue = newValue.trim() |
|
|
|
|
|
|
|
|
if (!trimmedValue) return |
|
|
|
|
|
|
|
|
const isUrl = trimmedValue.startsWith('http://') || trimmedValue.startsWith('https://') |
|
|
|
|
|
|
|
|
if (isUrl) { |
|
|
|
|
|
if (trimmedValue.startsWith('http://localhost:45462')) { |
|
|
try { |
|
|
const url = new URL(trimmedValue) |
|
|
const code = url.searchParams.get('code') |
|
|
|
|
|
if (code) { |
|
|
|
|
|
setupTokenAuthCode.value = code |
|
|
showToast('成功提取授权码!', 'success') |
|
|
|
|
|
} else { |
|
|
|
|
|
showToast('URL 中未找到授权码参数,请检查链接是否正确', 'error') |
|
|
} |
|
|
} catch (error) { |
|
|
|
|
|
|
|
|
showToast('链接格式错误,请检查是否为完整的 URL', 'error') |
|
|
} |
|
|
} else { |
|
|
|
|
|
showToast('请粘贴以 http://localhost:45462 开头的链接', 'error') |
|
|
} |
|
|
} |
|
|
|
|
|
}) |
|
|
|
|
|
|
|
|
watch( |
|
|
() => form.value.platform, |
|
|
(newPlatform) => { |
|
|
|
|
|
if (!isEdit.value) { |
|
|
emit('platform-changed', newPlatform) |
|
|
} |
|
|
} |
|
|
) |
|
|
|
|
|
|
|
|
watch( |
|
|
() => form.value.accountType, |
|
|
(newType) => { |
|
|
if (newType === 'group') { |
|
|
|
|
|
if (groups.value.length === 0) { |
|
|
loadGroups() |
|
|
} |
|
|
} |
|
|
} |
|
|
) |
|
|
|
|
|
|
|
|
watch( |
|
|
() => form.value.groupId, |
|
|
(newGroupId) => { |
|
|
if (newGroupId === '__new__') { |
|
|
|
|
|
form.value.groupId = '' |
|
|
showGroupManagement.value = true |
|
|
} |
|
|
} |
|
|
) |
|
|
|
|
|
|
|
|
const addModelMapping = () => { |
|
|
modelMappings.value.push({ from: '', to: '' }) |
|
|
} |
|
|
|
|
|
|
|
|
const removeModelMapping = (index) => { |
|
|
modelMappings.value.splice(index, 1) |
|
|
} |
|
|
|
|
|
|
|
|
const addPresetMapping = (from, to) => { |
|
|
|
|
|
const exists = modelMappings.value.some((mapping) => mapping.from === from) |
|
|
if (exists) { |
|
|
showToast(`模型 ${from} 的映射已存在`, 'info') |
|
|
return |
|
|
} |
|
|
|
|
|
modelMappings.value.push({ from, to }) |
|
|
showToast(`已添加映射: ${from} → ${to}`, 'success') |
|
|
} |
|
|
|
|
|
|
|
|
const convertMappingsToObject = () => { |
|
|
const mapping = {} |
|
|
|
|
|
if (modelRestrictionMode.value === 'whitelist') { |
|
|
|
|
|
allowedModels.value.forEach((model) => { |
|
|
mapping[model] = model |
|
|
}) |
|
|
} else { |
|
|
|
|
|
modelMappings.value.forEach((item) => { |
|
|
if (item.from && item.to) { |
|
|
mapping[item.from] = item.to |
|
|
} |
|
|
}) |
|
|
} |
|
|
|
|
|
return Object.keys(mapping).length > 0 ? mapping : null |
|
|
} |
|
|
|
|
|
|
|
|
watch( |
|
|
() => props.account, |
|
|
(newAccount) => { |
|
|
if (newAccount) { |
|
|
initModelMappings() |
|
|
|
|
|
const proxyConfig = normalizeProxyFormState(newAccount.proxy) |
|
|
const normalizedAuthMethod = |
|
|
typeof newAccount.authenticationMethod === 'string' |
|
|
? newAccount.authenticationMethod.trim().toLowerCase() |
|
|
: '' |
|
|
const derivedAddType = |
|
|
normalizedAuthMethod === 'api_key' |
|
|
? 'apikey' |
|
|
: normalizedAuthMethod === 'manual' |
|
|
? 'manual' |
|
|
: 'oauth' |
|
|
|
|
|
|
|
|
let groupId = '' |
|
|
if (newAccount.accountType === 'group') { |
|
|
groupId = newAccount.groupId || (newAccount.groupInfo && newAccount.groupInfo.id) || '' |
|
|
} |
|
|
|
|
|
|
|
|
let subscriptionType = 'claude_max' |
|
|
if (newAccount.subscriptionInfo) { |
|
|
const info = |
|
|
typeof newAccount.subscriptionInfo === 'string' |
|
|
? JSON.parse(newAccount.subscriptionInfo) |
|
|
: newAccount.subscriptionInfo |
|
|
|
|
|
if (info.accountType) { |
|
|
subscriptionType = info.accountType |
|
|
} else if (info.hasClaudeMax) { |
|
|
subscriptionType = 'claude_max' |
|
|
} else if (info.hasClaudePro) { |
|
|
subscriptionType = 'claude_pro' |
|
|
} else { |
|
|
subscriptionType = 'claude_free' |
|
|
} |
|
|
} |
|
|
|
|
|
form.value = { |
|
|
platform: newAccount.platform, |
|
|
addType: derivedAddType, |
|
|
name: newAccount.name, |
|
|
description: newAccount.description || '', |
|
|
accountType: newAccount.accountType || 'shared', |
|
|
subscriptionType: subscriptionType, |
|
|
autoStopOnWarning: newAccount.autoStopOnWarning || false, |
|
|
useUnifiedUserAgent: newAccount.useUnifiedUserAgent || false, |
|
|
useUnifiedClientId: newAccount.useUnifiedClientId || false, |
|
|
unifiedClientId: newAccount.unifiedClientId || '', |
|
|
groupId: groupId, |
|
|
groupIds: [], |
|
|
projectId: newAccount.projectId || '', |
|
|
accessToken: '', |
|
|
refreshToken: '', |
|
|
authenticationMethod: newAccount.authenticationMethod || '', |
|
|
apiKeysInput: '', |
|
|
apiKeyUpdateMode: 'append', |
|
|
proxy: proxyConfig, |
|
|
|
|
|
apiUrl: newAccount.apiUrl || '', |
|
|
apiKey: '', |
|
|
priority: newAccount.priority || 50, |
|
|
supportedModels: (() => { |
|
|
const models = newAccount.supportedModels |
|
|
if (!models) return [] |
|
|
|
|
|
if (typeof models === 'object' && !Array.isArray(models)) { |
|
|
return Object.keys(models) |
|
|
} |
|
|
|
|
|
if (Array.isArray(models)) { |
|
|
return models |
|
|
} |
|
|
return [] |
|
|
})(), |
|
|
userAgent: newAccount.userAgent || '', |
|
|
enableRateLimit: |
|
|
newAccount.rateLimitDuration && newAccount.rateLimitDuration > 0 ? true : false, |
|
|
rateLimitDuration: newAccount.rateLimitDuration || 60, |
|
|
|
|
|
accessKeyId: '', |
|
|
secretAccessKey: '', |
|
|
region: newAccount.region || '', |
|
|
sessionToken: '', |
|
|
defaultModel: newAccount.defaultModel || '', |
|
|
smallFastModel: newAccount.smallFastModel || '', |
|
|
|
|
|
azureEndpoint: newAccount.azureEndpoint || '', |
|
|
apiVersion: newAccount.apiVersion || '', |
|
|
deploymentName: newAccount.deploymentName || '', |
|
|
|
|
|
baseApi: newAccount.baseApi || '', |
|
|
|
|
|
dailyQuota: newAccount.dailyQuota || 0, |
|
|
dailyUsage: newAccount.dailyUsage || 0, |
|
|
quotaResetTime: newAccount.quotaResetTime || '00:00' |
|
|
} |
|
|
|
|
|
|
|
|
if (newAccount.platform === 'claude-console') { |
|
|
loadAccountUsage() |
|
|
} |
|
|
|
|
|
|
|
|
if (newAccount.accountType === 'group') { |
|
|
|
|
|
loadGroups().then(async () => { |
|
|
const foundGroupIds = [] |
|
|
|
|
|
|
|
|
if (newAccount.groupInfo && newAccount.groupInfo.id) { |
|
|
form.value.groupId = newAccount.groupInfo.id |
|
|
foundGroupIds.push(newAccount.groupInfo.id) |
|
|
} else if (newAccount.groupId) { |
|
|
|
|
|
form.value.groupId = newAccount.groupId |
|
|
foundGroupIds.push(newAccount.groupId) |
|
|
} else if ( |
|
|
newAccount.groupIds && |
|
|
Array.isArray(newAccount.groupIds) && |
|
|
newAccount.groupIds.length > 0 |
|
|
) { |
|
|
|
|
|
form.value.groupId = newAccount.groupIds[0] |
|
|
foundGroupIds.push(...newAccount.groupIds) |
|
|
} else { |
|
|
|
|
|
const checkPromises = groups.value.map(async (group) => { |
|
|
try { |
|
|
const response = await apiClient.get(`/admin/account-groups/${group.id}/members`) |
|
|
const members = response.data || [] |
|
|
if (members.some((m) => m.id === newAccount.id)) { |
|
|
foundGroupIds.push(group.id) |
|
|
if (!form.value.groupId) { |
|
|
form.value.groupId = group.id |
|
|
} |
|
|
} |
|
|
} catch (error) { |
|
|
|
|
|
} |
|
|
}) |
|
|
|
|
|
await Promise.all(checkPromises) |
|
|
} |
|
|
|
|
|
|
|
|
form.value.groupIds = foundGroupIds |
|
|
}) |
|
|
} |
|
|
} |
|
|
}, |
|
|
{ immediate: true } |
|
|
) |
|
|
|
|
|
|
|
|
const fetchUnifiedUserAgent = async () => { |
|
|
try { |
|
|
const response = await apiClient.get('/admin/claude-code-version') |
|
|
if (response.success && response.userAgent) { |
|
|
unifiedUserAgent.value = response.userAgent |
|
|
} else { |
|
|
unifiedUserAgent.value = '' |
|
|
} |
|
|
} catch (error) { |
|
|
|
|
|
unifiedUserAgent.value = '' |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const clearUnifiedCache = async () => { |
|
|
clearingCache.value = true |
|
|
try { |
|
|
const response = await apiClient.post('/admin/claude-code-version/clear') |
|
|
if (response.success) { |
|
|
unifiedUserAgent.value = '' |
|
|
showToast('统一User-Agent缓存已清除', 'success') |
|
|
} else { |
|
|
showToast('清除缓存失败', 'error') |
|
|
} |
|
|
} catch (error) { |
|
|
|
|
|
showToast('清除缓存失败:' + (error.message || '未知错误'), 'error') |
|
|
} finally { |
|
|
clearingCache.value = false |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const generateClientId = () => { |
|
|
|
|
|
const bytes = new Uint8Array(32) |
|
|
crypto.getRandomValues(bytes) |
|
|
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('') |
|
|
} |
|
|
|
|
|
|
|
|
const regenerateClientId = () => { |
|
|
form.value.unifiedClientId = generateClientId() |
|
|
showToast('已生成新的客户端标识', 'success') |
|
|
} |
|
|
|
|
|
|
|
|
const handleUnifiedClientIdChange = () => { |
|
|
if (form.value.useUnifiedClientId) { |
|
|
|
|
|
form.value.useUnifiedUserAgent = true |
|
|
|
|
|
if (!form.value.unifiedClientId) { |
|
|
form.value.unifiedClientId = generateClientId() |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
const minDateTime = computed(() => { |
|
|
const now = new Date() |
|
|
now.setMinutes(now.getMinutes() + 1) |
|
|
return now.toISOString().slice(0, 16) |
|
|
}) |
|
|
|
|
|
|
|
|
const updateAccountExpireAt = () => { |
|
|
if (!form.value.expireDuration) { |
|
|
form.value.expiresAt = null |
|
|
return |
|
|
} |
|
|
|
|
|
if (form.value.expireDuration === 'custom') { |
|
|
return |
|
|
} |
|
|
|
|
|
const now = new Date() |
|
|
const duration = form.value.expireDuration |
|
|
const match = duration.match(/(\d+)([d])/) |
|
|
|
|
|
if (match) { |
|
|
const [, value, unit] = match |
|
|
const num = parseInt(value) |
|
|
|
|
|
if (unit === 'd') { |
|
|
now.setDate(now.getDate() + num) |
|
|
} |
|
|
|
|
|
form.value.expiresAt = now.toISOString() |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const updateAccountCustomExpireAt = () => { |
|
|
if (form.value.customExpireDate) { |
|
|
form.value.expiresAt = new Date(form.value.customExpireDate).toISOString() |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const formatExpireDate = (dateString) => { |
|
|
if (!dateString) return '' |
|
|
const date = new Date(dateString) |
|
|
return date.toLocaleString('zh-CN', { |
|
|
year: 'numeric', |
|
|
month: '2-digit', |
|
|
day: '2-digit', |
|
|
hour: '2-digit', |
|
|
minute: '2-digit' |
|
|
}) |
|
|
} |
|
|
|
|
|
|
|
|
onMounted(() => { |
|
|
|
|
|
platformGroup.value = determinePlatformGroup(form.value.platform) |
|
|
|
|
|
|
|
|
if (isEdit.value) { |
|
|
initModelMappings() |
|
|
} |
|
|
|
|
|
|
|
|
fetchUnifiedUserAgent() |
|
|
|
|
|
if (isEdit.value && props.account?.platform === 'claude-console') { |
|
|
loadAccountUsage() |
|
|
} |
|
|
}) |
|
|
|
|
|
|
|
|
watch( |
|
|
() => form.value.platform, |
|
|
(newPlatform) => { |
|
|
if (newPlatform === 'claude') { |
|
|
fetchUnifiedUserAgent() |
|
|
} |
|
|
} |
|
|
) |
|
|
</script> |
|
|
|
|
|
<style scoped> |
|
|
@keyframes fadeIn { |
|
|
from { |
|
|
opacity: 0; |
|
|
transform: translateY(10px); |
|
|
} |
|
|
to { |
|
|
opacity: 1; |
|
|
transform: translateY(0); |
|
|
} |
|
|
} |
|
|
|
|
|
.animate-fadeIn { |
|
|
animation: fadeIn 0.3s ease-out; |
|
|
} |
|
|
</style> |
|
|
|