Files
claude-relay-service/web/admin-spa/src/components/accounts/AccountForm.vue

6324 lines
263 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>
<!-- 步骤1: 基本信息和代理设置 -->
<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">
<!-- Claude 分组 -->
<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>
<!-- OpenAI 分组 -->
<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>
<!-- Gemini 分组 -->
<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>
<!-- Droid 分组 -->
<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">
<!-- Claude 子选项 -->
<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>
<!-- OpenAI 子选项 -->
<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>
<!-- Gemini 子选项 -->
<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>
<label
class="group relative flex cursor-pointer items-center rounded-md border p-2 transition-all"
:class="[
form.platform === 'gemini-api'
? 'border-amber-500 bg-amber-50 dark:border-amber-400 dark:bg-amber-900/30'
: 'border-gray-300 bg-white hover:border-amber-400 hover:bg-amber-50/50 dark:border-gray-600 dark:bg-gray-700 dark:hover:border-amber-500 dark:hover:bg-amber-900/20'
]"
>
<input
v-model="form.platform"
class="sr-only"
type="radio"
value="gemini-api"
/>
<div class="flex items-center gap-2">
<i class="fas fa-key text-sm text-amber-600 dark:text-amber-400"></i>
<div>
<span class="block text-xs font-medium text-gray-900 dark:text-gray-100"
>Gemini API</span
>
<span class="text-xs text-gray-500 dark:text-gray-400">API Key</span>
</div>
</div>
<div
v-if="form.platform === 'gemini-api'"
class="absolute right-1 top-1 flex h-4 w-4 items-center justify-center rounded-full bg-amber-500"
>
<i class="fas fa-check text-xs text-white"></i>
</div>
</label>
</template>
<!-- Droid 子选项 -->
<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' &&
form.platform !== 'gemini-api'
"
>
<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>
<!-- 到期时间 - 仅在创建账户时显示编辑时使用独立的过期时间编辑弹窗Gemini API 不需要 -->
<div v-if="!isEdit && form.platform !== 'gemini-api'">
<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>
<!-- Gemini 项目 ID 字段 -->
<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">项目 IDProject ID</span
>,通常是字符串格式
</li>
<li class="text-red-600">
⚠️ 注意:要复制项目 IDProject ID不要复制项目编号Project Number
</li>
</ol>
</div>
<p class="mt-2">
<strong>提示:</strong>如果您的账号是普通个人账号(未绑定 Google
Cloud请留空此字段。
</p>
</div>
</div>
</div>
</div>
<!-- Bedrock 特定字段 -->
<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>
<!-- Azure OpenAI 特定字段 -->
<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>
<!-- Claude Console 和 CCR 特定字段 -->
<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>
<input
v-model.number="form.maxConcurrentTasks"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
min="0"
placeholder="0 表示不限制"
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
>
<!-- 模式切换 -->
<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-opus-4-5-20251101', 'claude-opus-4-5-20251101')
"
>
+ Opus 4.5
</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-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 v-if="form.platform === 'claude-console'">
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>上游错误处理</label
>
<label class="inline-flex cursor-pointer items-center">
<input
v-model="form.disableAutoProtection"
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">
勾选后遇到 401/400/429/529 等上游错误仅记录日志并透传,不自动禁用或限流
</p>
</div>
</div>
<!-- OpenAI-Responses 特定字段 -->
<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>
<!-- 限流时长字段 - 隐藏不显示使用默认值60 -->
<input v-model.number="form.rateLimitDuration" type="hidden" value="60" />
</div>
<!-- Gemini API 配置 -->
<div v-if="form.platform === 'gemini-api' && !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.baseUrl"
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 dark:border-red-400': errors.baseUrl }"
placeholder="https://generativelanguage.googleapis.com/v1beta/models"
required
type="url"
/>
<p v-if="errors.baseUrl" class="mt-1 text-xs text-red-500 dark:text-red-400">
{{ errors.baseUrl }}
</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
填写 API 基础地址,必须以
<code class="rounded bg-gray-100 px-1 dark:bg-gray-600">/models</code>
结尾。系统会自动拼接
<code class="rounded bg-gray-100 px-1 dark:bg-gray-600"
>/{model}:generateContent</code
>
</p>
<p class="mt-0.5 text-xs text-gray-400 dark:text-gray-500">
官方:
<code class="rounded bg-gray-100 px-1 dark:bg-gray-600"
>https://generativelanguage.googleapis.com/v1beta/models</code
>
</p>
<p class="mt-0.5 text-xs text-gray-400 dark:text-gray-500">
上游为 CRS:
<code class="rounded bg-gray-100 px-1 dark:bg-gray-600"
>https://your-crs.com/gemini/v1beta/models</code
>
</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="AIzaSy..."
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">
从 Google AI Studio 获取的 API 密钥
</p>
</div>
</div>
<!-- Claude 订阅类型选择 -->
<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>
<!-- Claude 5小时限制自动停止调度选项 -->
<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>
<!-- Claude 账户级串行队列开关 -->
<div v-if="form.platform === 'claude'" class="mt-4">
<label class="flex items-start">
<input
v-model="form.serialQueueEnabled"
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">
启用账户级串行队列
</span>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
开启后强制该账户的用户消息串行处理,忽略全局串行队列设置。适用于并发限制较低的账户。
</p>
</div>
</label>
</div>
<!-- Claude User-Agent 版本配置 -->
<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>
<!-- Claude 统一客户端标识配置 -->
<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>
<!-- 手动输入 Token 字段 -->
<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/oauth_creds.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 TokenToken 过期后需要手动更新。
</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>
<!-- Droid User-Agent 配置 (OAuth/Manual 模式) -->
<div v-if="form.platform === 'droid'">
<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="factory-cli/0.32.1"
type="text"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
留空使用默认值 factory-cli/0.32.1,可根据需要自定义
</p>
</div>
</div>
<!-- API Key 模式输入 -->
<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>
<!-- Droid User-Agent 配置 -->
<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="factory-cli/0.32.1"
type="text"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
留空使用默认值 factory-cli/0.32.1,可根据需要自定义
</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' &&
form.platform !== 'gemini-api'
"
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>
<!-- 步骤2: OAuth授权 -->
<OAuthFlow
v-if="oauthStep === 2 && form.addType === 'oauth'"
ref="oauthFlowRef"
:platform="form.platform"
:proxy="form.proxy"
@back="oauthStep = 1"
@success="handleOAuthSuccess"
/>
<!-- 步骤2: Setup Token授权 -->
<div v-if="oauthStep === 2 && form.addType === 'setup-token'" class="space-y-6">
<!-- Claude Setup Token流程 -->
<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>
<!-- 授权方式选择 -->
<div class="mb-4">
<p class="mb-3 text-sm font-medium text-blue-800 dark:text-blue-300">
选择授权方式:
</p>
<div class="flex flex-wrap gap-4">
<label class="flex cursor-pointer items-center">
<input
v-model="authMethod"
class="mr-2 text-blue-600 focus:ring-blue-500"
type="radio"
value="manual"
@change="onAuthMethodChange"
/>
<span class="text-sm text-blue-800 dark:text-blue-300">
<i class="fas fa-link mr-1" />手动授权
</span>
</label>
<label class="flex cursor-pointer items-center">
<input
v-model="authMethod"
class="mr-2 text-blue-600 focus:ring-blue-500"
type="radio"
value="cookie"
@change="onAuthMethodChange"
/>
<span class="text-sm text-blue-800 dark:text-blue-300">
<i class="fas fa-cookie mr-1" />Cookie 自动授权
</span>
</label>
</div>
</div>
<!-- 手动授权流程 -->
<div v-if="authMethod === 'manual'" class="space-y-4">
<p class="mb-4 text-sm text-blue-800 dark:text-blue-300">
请按照以下步骤通过 Setup Token 完成 Claude 账户的授权:
</p>
<!-- 步骤1: 生成授权链接 -->
<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>
<!-- 步骤2: 访问链接并授权 -->
<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>
<!-- 步骤3: 输入授权码 -->
<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>
<!-- Cookie自动授权流程 -->
<div v-if="authMethod === 'cookie'" class="space-y-4">
<p class="mb-4 text-sm text-blue-800 dark:text-blue-300">
使用 sessionKey 自动完成授权,无需手动打开链接。
</p>
<div
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
>
<div class="space-y-4">
<div>
<label
class="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-700 dark:text-gray-300"
>
<i class="fas fa-cookie text-blue-500" />sessionKey
<span
v-if="parsedSessionKeyCount > 1"
class="rounded-full bg-blue-500 px-2 py-0.5 text-xs text-white"
>
{{ parsedSessionKeyCount }} 个
</span>
</label>
<textarea
v-model="sessionKey"
class="form-input w-full resize-y border-gray-300 font-mono text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
:class="{ 'border-red-500': cookieAuthError }"
placeholder="每行一个 sessionKey例如&#10;sk-ant-sid01-xxxxx...&#10;sk-ant-sid01-yyyyy..."
rows="3"
/>
<p
v-if="parsedSessionKeyCount > 1"
class="mt-1 text-xs text-blue-600 dark:text-blue-400"
>
<i class="fas fa-info-circle mr-1" />
将批量创建 {{ parsedSessionKeyCount }} 个账户
</p>
<p v-if="cookieAuthError" class="mt-1 text-xs text-red-500">
{{ cookieAuthError }}
</p>
</div>
<!-- 帮助说明 -->
<div>
<button
class="flex items-center text-xs text-blue-600 hover:text-blue-700"
type="button"
@click="showSessionKeyHelp = !showSessionKeyHelp"
>
<i
:class="
showSessionKeyHelp
? 'fas fa-chevron-down mr-1'
: 'fas fa-chevron-right mr-1'
"
/>
如何获取 sessionKey
</button>
<div
v-if="showSessionKeyHelp"
class="mt-3 rounded border border-gray-200 bg-gray-50 p-3 dark:border-gray-600 dark:bg-gray-700"
>
<ol class="space-y-2 text-xs text-gray-600 dark:text-gray-300">
<li>1. 在浏览器中登录 <strong>claude.ai</strong></li>
<li>2. 按 <strong>F12</strong> 打开开发者工具</li>
<li>3. 切换到 <strong>"Application"</strong> (应用) 标签页</li>
<li>
4. 在左侧选择 <strong>"Cookies"</strong> →
<strong>"https://claude.ai"</strong>
</li>
<li>5. 找到键为 <strong>"sessionKey"</strong> 的那一行</li>
<li>6. 复制其 <strong>"Value"</strong> (值) 列的内容</li>
</ol>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
<i class="fas fa-info-circle mr-1" />
sessionKey 通常以 "sk-ant-" 开头
</p>
</div>
</div>
<!-- 开始授权按钮 -->
<button
class="btn btn-primary w-full px-4 py-3"
:disabled="cookieAuthLoading || !sessionKey.trim()"
type="button"
@click="handleCookieAuth"
>
<div v-if="cookieAuthLoading" class="loading-spinner mr-2" />
<i v-else class="fas fa-magic mr-2" />
<template v-if="cookieAuthLoading && batchProgress.total > 1">
正在授权 {{ batchProgress.current }}/{{ batchProgress.total }}...
</template>
<template v-else-if="cookieAuthLoading"> 授权中... </template>
<template v-else> 开始自动授权 </template>
</button>
</div>
</div>
<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>如果您设置了代理Cookie授权也会使用相同的代理配置。
</p>
</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
v-if="authMethod === 'manual'"
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>
<!-- Gemini 项目 ID 字段 -->
<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>
<!-- Claude 订阅类型选择(编辑模式) -->
<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>
<!-- Claude 5小时限制自动停止调度选项编辑模式 -->
<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>
<!-- Claude 账户级串行队列开关(编辑模式) -->
<div v-if="form.platform === 'claude'" class="mt-4">
<label class="flex items-start">
<input
v-model="form.serialQueueEnabled"
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">
启用账户级串行队列
</span>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
开启后强制该账户的用户消息串行处理,忽略全局串行队列设置。适用于并发限制较低的账户。
</p>
</div>
</label>
</div>
<!-- Claude User-Agent 版本配置(编辑模式) -->
<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>
<!-- Claude 统一客户端标识配置(编辑模式) -->
<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>
<!-- Claude Console 和 CCR 特定字段(编辑模式)-->
<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>
<input
v-model.number="form.maxConcurrentTasks"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
min="0"
placeholder="0 表示不限制"
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
>
<!-- 模式切换 -->
<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 v-if="form.platform === 'claude-console'">
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">
上游错误处理
</label>
<label class="inline-flex cursor-pointer items-center">
<input
v-model="form.disableAutoProtection"
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">
勾选后遇到 401/400/429/529 等上游错误仅记录日志并透传,不自动禁用或限流
</p>
</div>
</div>
<!-- OpenAI-Responses 特定字段(编辑模式)-->
<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>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">
最大并发任务数
</label>
<input
v-model.number="form.maxConcurrentTasks"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
min="0"
placeholder="0 表示不限制"
type="number"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
限制该账户的并发请求数量0 表示不限制
</p>
</div>
</div>
<!-- Gemini API 特定字段(编辑模式)-->
<div v-if="form.platform === 'gemini-api'" 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.baseUrl"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
:class="{ 'border-red-500 dark:border-red-400': errors.baseUrl }"
placeholder="https://generativelanguage.googleapis.com/v1beta/models"
type="url"
/>
<p v-if="errors.baseUrl" class="mt-1 text-xs text-red-500 dark:text-red-400">
{{ errors.baseUrl }}
</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
填写 API 基础地址,必须以
<code class="rounded bg-gray-100 px-1 dark:bg-gray-600">/models</code>
结尾。系统会自动拼接
<code class="rounded bg-gray-100 px-1 dark:bg-gray-600"
>/{model}:generateContent</code
>
</p>
<p class="mt-0.5 text-xs text-gray-400 dark:text-gray-500">
官方:
<code class="rounded bg-gray-100 px-1 dark:bg-gray-600"
>https://generativelanguage.googleapis.com/v1beta/models</code
>
</p>
<p class="mt-0.5 text-xs text-gray-400 dark:text-gray-500">
上游为 CRS:
<code class="rounded bg-gray-100 px-1 dark:bg-gray-600"
>https://your-crs.com/gemini/v1beta/models</code
>
</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"
placeholder="留空表示不更新"
: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 Key</p>
</div>
</div>
<!-- Bedrock 特定字段(编辑模式)-->
<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>
<!-- Azure OpenAI 特定字段(编辑模式)-->
<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>
<!-- Token 更新 -->
<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>
<!-- Droid User-Agent 配置 (编辑模式) -->
<div v-if="form.platform === 'droid'">
<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="factory-cli/0.32.1"
type="text"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
留空使用默认值 factory-cli/0.32.1,可根据需要自定义
</p>
</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"
/>
<!-- API Key 管理模态框 -->
<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)
// OAuthFlow 组件引用
const oauthFlowRef = ref(null)
// OAuth步骤
const oauthStep = ref(1)
const loading = ref(false)
const showApiKey = ref(false)
// Setup Token 相关状态
const setupTokenLoading = ref(false)
const setupTokenExchanging = ref(false)
const setupTokenAuthUrl = ref('')
const setupTokenAuthCode = ref('')
const setupTokenCopied = ref(false)
const setupTokenSessionId = ref('')
// Cookie自动授权相关状态
const authMethod = ref('manual') // 'manual' | 'cookie'
const sessionKey = ref('')
const cookieAuthLoading = ref(false)
const cookieAuthError = ref('')
const showSessionKeyHelp = ref(false)
const batchProgress = ref({ current: 0, total: 0 }) // 批量进度
// 解析后的 sessionKey 数量
const parsedSessionKeyCount = computed(() => {
return sessionKey.value
.split('\n')
.map((s) => s.trim())
.filter((s) => s.length > 0).length
})
// Claude Code 统一 User-Agent 信息
const unifiedUserAgent = ref('')
const clearingCache = ref(false)
// 客户端标识编辑状态(已废弃,不再需要编辑功能)
// const editingClientId = ref(false)
// 平台分组状态
const platformGroup = ref('')
// API Key 管理模态框
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 (['gemini', 'gemini-api'].includes(platform)) {
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', // 默认为 Claude Max兼容旧数据
autoStopOnWarning: props.account?.autoStopOnWarning || false, // 5小时限制自动停止调度
useUnifiedUserAgent: props.account?.useUnifiedUserAgent || false, // 使用统一Claude Code版本
useUnifiedClientId: props.account?.useUnifiedClientId || false, // 使用统一的客户端标识
unifiedClientId: props.account?.unifiedClientId || '', // 统一的客户端标识
serialQueueEnabled: (props.account?.maxConcurrency || 0) > 0, // 账户级串行队列开关
groupId: '',
groupIds: [],
projectId: props.account?.projectId || '',
accessToken: '',
refreshToken: '',
apiKeysInput: '',
apiKeyUpdateMode: 'append',
proxy: initProxyConfig(),
// Claude Console 特定字段
apiUrl: props.account?.apiUrl || '',
apiKey: props.account?.apiKey || '',
priority: props.account?.priority || 50,
endpointType: props.account?.endpointType || 'anthropic',
// OpenAI-Responses 特定字段
baseApi: props.account?.baseApi || '',
// Gemini-API 特定字段
baseUrl: props.account?.baseUrl || 'https://generativelanguage.googleapis.com',
rateLimitDuration: props.account?.rateLimitDuration || 60,
supportedModels: (() => {
const models = props.account?.supportedModels
if (!models) return []
// 处理对象格式Claude Console 的新格式)
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,
disableAutoProtection: props.account?.disableAutoProtection === true,
// 额度管理字段
dailyQuota: props.account?.dailyQuota || 0,
dailyUsage: props.account?.dailyUsage || 0,
quotaResetTime: props.account?.quotaResetTime || '00:00',
// 并发控制字段
maxConcurrentTasks: props.account?.maxConcurrentTasks || 0,
// Bedrock 特定字段
accessKeyId: props.account?.accessKeyId || '',
secretAccessKey: props.account?.secretAccessKey || '',
region: props.account?.region || '',
sessionToken: props.account?.sessionToken || '',
defaultModel: props.account?.defaultModel || '',
smallFastModel: props.account?.smallFastModel || '',
// Azure OpenAI 特定字段
azureEndpoint: props.account?.azureEndpoint || '',
apiVersion: props.account?.apiVersion || '',
deploymentName: props.account?.deploymentName || '',
// 到期时间字段
expireDuration: (() => {
// 编辑时根据expiresAt初始化expireDuration
if (props.account?.expiresAt) {
return 'custom' // 如果有过期时间,默认显示为自定义
}
return ''
})(),
customExpireDate: (() => {
// 编辑时根据expiresAt初始化customExpireDate
if (props.account?.expiresAt) {
// 转换ISO时间为datetime-local格式 (YYYY-MM-DDTHH:mm)
return new Date(props.account.expiresAt).toISOString().slice(0, 16)
}
return ''
})(),
expiresAt: props.account?.expiresAt || null
})
// 模型限制配置
const modelRestrictionMode = ref('whitelist') // 'whitelist' 或 'mapping'
const allowedModels = ref([
// 默认勾选所有 Sonnet 和 Haiku 模型
'claude-sonnet-4-20250514',
'claude-sonnet-4-5-20250929',
'claude-3-5-haiku-20241022'
]) // 白名单模式下选中的模型列表
// 常用模型列表
const commonModels = [
{ value: 'claude-opus-4-5-20251101', label: 'Claude Opus 4.5', color: 'blue' },
{ 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显示勾选的模型
allowedModels.value = entries.map(([from]) => from)
// 同时保留 modelMappings以便用户切换到映射模式时有初始数据
modelMappings.value = entries.map(([from, to]) => ({ from, to }))
} else {
modelRestrictionMode.value = 'mapping'
// 映射模式:设置 modelMappings显示映射表
modelMappings.value = entries.map(([from, to]) => ({ from, to }))
// 不填充 allowedModels因为映射模式不使用白名单复选框
}
} else if (Array.isArray(props.account.supportedModels)) {
// 如果是数组格式(旧格式),转换为白名单模式
modelRestrictionMode.value = 'whitelist'
allowedModels.value = props.account.supportedModels
// 同时设置 modelMappings 为自映射
modelMappings.value = props.account.supportedModels.map((model) => ({
from: model,
to: model
}))
}
}
}
// 解析多行 API Key 输入
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
})
// 计算是否可以交换Setup Token code
const canExchangeSetupToken = computed(() => {
return setupTokenAuthUrl.value && setupTokenAuthCode.value.trim()
})
// 获取当前使用量(实时)
const calculateCurrentUsage = () => {
// 如果不是编辑模式或没有账户ID返回0
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
})
// 当前账户的 API Key 数量(仅用于展示)
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
})
// 编辑时判断是否为 API Key 模式的 Droid 账户
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 canCreate = computed(() => {
// if (form.value.addType === 'manual') {
// return form.value.name?.trim() && form.value.accessToken?.trim()
// }
// return form.value.name?.trim()
// })
// 选择平台分组
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
}
// 分组类型验证 - OAuth流程修复
if (
form.value.accountType === 'group' &&
(!form.value.groupIds || form.value.groupIds.length === 0)
) {
showToast('请选择一个分组', 'error')
return
}
// 数据同步:确保 groupId 和 groupIds 保持一致 - OAuth流程
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 = ''
}
}
// 对于Gemini账户检查项目 ID
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
}
// Setup Token 相关方法
// 生成Setup Token授权URL
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
}
}
// 重新生成Setup Token授权URL
const regenerateSetupTokenAuthUrl = () => {
setupTokenAuthUrl.value = ''
setupTokenAuthCode.value = ''
generateSetupTokenAuthUrl()
}
// 复制Setup Token授权URL
const copySetupTokenAuthUrl = async () => {
try {
await navigator.clipboard.writeText(setupTokenAuthUrl.value)
setupTokenCopied.value = true
showToast('链接已复制', 'success')
setTimeout(() => {
setupTokenCopied.value = false
}, 2000)
} catch (error) {
// 降级方案 - 使用 textarea 替代 input禁用 ESLint 警告
const textarea = document.createElement('textarea')
textarea.value = setupTokenAuthUrl.value
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
try {
// eslint-disable-next-line
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)
}
}
// 交换Setup Token授权码
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)
// Setup Token模式也需要确保生成客户端ID
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
}
}
// =============================================================================
// Cookie自动授权相关方法
// =============================================================================
// Cookie自动授权支持批量
const handleCookieAuth = async () => {
// 解析多行输入
const sessionKeys = sessionKey.value
.split('\n')
.map((s) => s.trim())
.filter((s) => s.length > 0)
if (sessionKeys.length === 0) {
cookieAuthError.value = '请输入至少一个 sessionKey'
return
}
cookieAuthLoading.value = true
cookieAuthError.value = ''
batchProgress.value = { current: 0, total: sessionKeys.length }
const isSetupToken = form.value.addType === 'setup-token'
const proxyPayload = buildProxyPayload(form.value.proxy)
const results = []
const errors = []
for (let i = 0; i < sessionKeys.length; i++) {
batchProgress.value.current = i + 1
try {
const payload = {
sessionKey: sessionKeys[i],
...(proxyPayload && { proxy: proxyPayload })
}
let result
if (isSetupToken) {
result = await accountsStore.oauthSetupTokenWithCookie(payload)
} else {
result = await accountsStore.oauthWithCookie(payload)
}
results.push(result)
} catch (error) {
errors.push({
index: i + 1,
key: sessionKeys[i].substring(0, 20) + '...',
error: error.message
})
}
}
batchProgress.value = { current: 0, total: 0 }
if (results.length > 0) {
try {
// 成功后处理OAuth数据传递数组
// cookieAuthLoading 保持 true直到账号创建完成
await handleOAuthSuccess(results)
} finally {
cookieAuthLoading.value = false
}
} else {
cookieAuthLoading.value = false
}
if (errors.length > 0 && results.length === 0) {
cookieAuthError.value = '全部授权失败,请检查 sessionKey 是否有效'
} else if (errors.length > 0) {
cookieAuthError.value = `${errors.length} 个授权失败`
}
}
// 重置Cookie授权状态
const resetCookieAuth = () => {
sessionKey.value = ''
cookieAuthError.value = ''
showSessionKeyHelp.value = false
batchProgress.value = { current: 0, total: 0 }
}
// 切换授权方式时重置状态
const onAuthMethodChange = () => {
// 切换到手动模式时清除Cookie相关状态
if (authMethod.value === 'manual') {
resetCookieAuth()
} else {
// 切换到Cookie模式时清除手动授权状态
setupTokenAuthUrl.value = ''
setupTokenAuthCode.value = ''
setupTokenSessionId.value = ''
}
}
// 构建 Claude 账户数据(辅助函数)
const buildClaudeAccountData = (tokenInfo, accountName, clientId) => {
const proxyPayload = buildProxyPayload(form.value.proxy)
const claudeOauthPayload = tokenInfo.claudeAiOauth || tokenInfo
const data = {
name: accountName,
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,
claudeAiOauth: claudeOauthPayload,
priority: form.value.priority || 50,
autoStopOnWarning: form.value.autoStopOnWarning || false,
useUnifiedUserAgent: form.value.useUnifiedUserAgent || false,
useUnifiedClientId: form.value.useUnifiedClientId || false,
unifiedClientId: clientId,
maxConcurrency: form.value.serialQueueEnabled ? 1 : 0,
subscriptionInfo: {
accountType: form.value.subscriptionType || 'claude_max',
hasClaudeMax: form.value.subscriptionType === 'claude_max',
hasClaudePro: form.value.subscriptionType === 'claude_pro',
manuallySet: true
}
}
// 处理 extInfo
if (claudeOauthPayload) {
const extInfoPayload = {}
const extSource = claudeOauthPayload.extInfo
if (extSource?.org_uuid) extInfoPayload.org_uuid = extSource.org_uuid
if (extSource?.account_uuid) extInfoPayload.account_uuid = extSource.account_uuid
if (!extSource) {
if (claudeOauthPayload.organization?.uuid) {
extInfoPayload.org_uuid = claudeOauthPayload.organization.uuid
}
if (claudeOauthPayload.account?.uuid) {
extInfoPayload.account_uuid = claudeOauthPayload.account.uuid
}
}
if (Object.keys(extInfoPayload).length > 0) {
data.extInfo = extInfoPayload
}
}
return data
}
// 处理OAuth成功支持批量
const handleOAuthSuccess = async (tokenInfoOrList) => {
loading.value = true
try {
const currentPlatform = form.value.platform
// Claude 平台支持批量创建
if (currentPlatform === 'claude' && Array.isArray(tokenInfoOrList)) {
const tokenInfoList = tokenInfoOrList
const isBatch = tokenInfoList.length > 1
const baseName = form.value.name
const results = []
const errors = []
for (let i = 0; i < tokenInfoList.length; i++) {
const tokenInfo = tokenInfoList[i]
// 批量时自动命名
const accountName = isBatch ? `${baseName}_${i + 1}` : baseName
// 如果启用统一客户端标识,为每个账户生成独立 ID
const clientId = form.value.useUnifiedClientId ? generateClientId() : ''
const data = buildClaudeAccountData(tokenInfo, accountName, clientId)
try {
const result = await accountsStore.createClaudeAccount(data)
results.push(result)
} catch (error) {
errors.push({ name: accountName, error: error.message })
}
}
// 处理结果
if (results.length > 0) {
const msg = isBatch
? `成功创建 ${results.length}/${tokenInfoList.length} 个账户`
: '账户创建成功'
showToast(msg, 'success')
emit('success', results[0]) // 兼容单个创建的返回
}
if (errors.length > 0) {
showToast(`${errors.length} 个账户创建失败`, 'error')
}
return
}
// 单个 tokenInfo 或其他平台的处理(保持原有逻辑)
const tokenInfo = Array.isArray(tokenInfoOrList) ? tokenInfoOrList[0] : tokenInfoOrList
// OAuth模式也需要确保生成客户端ID
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
}
if (currentPlatform === 'claude') {
// Claude使用claudeAiOauth字段
const claudeOauthPayload = tokenInfo.claudeAiOauth || tokenInfo
data.claudeAiOauth = claudeOauthPayload
if (claudeOauthPayload) {
const extInfoPayload = {}
const extSource = claudeOauthPayload.extInfo
if (extSource && typeof extSource === 'object') {
if (extSource.org_uuid) {
extInfoPayload.org_uuid = extSource.org_uuid
}
if (extSource.account_uuid) {
extInfoPayload.account_uuid = extSource.account_uuid
}
}
if (!extSource) {
const orgUuid = claudeOauthPayload.organization?.uuid
const accountUuid = claudeOauthPayload.account?.uuid
if (orgUuid) {
extInfoPayload.org_uuid = orgUuid
}
if (accountUuid) {
extInfoPayload.account_uuid = accountUuid
}
}
if (Object.keys(extInfoPayload).length > 0) {
data.extInfo = extInfoPayload
}
}
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.maxConcurrency = form.value.serialQueueEnabled ? 1 : 0
// 添加订阅类型信息
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') {
// Gemini使用geminiOauth字段
data.geminiOauth = tokenInfo.tokens || tokenInfo
if (form.value.projectId) {
data.projectId = form.value.projectId
}
// 添加 Gemini 优先级
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}`
}
// 如果有详细的 OAuth 错误信息,也显示出来
if (errorDetails && errorDetails.error_description) {
fullMessage += `\n详细信息: ${errorDetails.error_description}`
} else if (errorDetails && errorDetails.error && errorDetails.error.message) {
// 处理 OpenAI 格式的错误
fullMessage += `\n详细信息: ${errorDetails.error.message}`
}
showToast(fullMessage, 'error', '', 8000)
// 错误已通过 toast 显示给用户
} finally {
loading.value = false
// 重置 OAuthFlow 组件的加载状态(如果是通过 OAuth 模式调用)
oauthFlowRef.value?.resetCookieAuth()
}
}
// 创建账户(手动模式)
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
}
// Claude Console 验证
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
}
}
// CCR (Claude Code Router) 验证 - 使用与 Claude Console 相同的字段
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
}
}
// OpenAI-Responses 验证
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') {
// 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') {
// 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') {
// 手动模式验证 - 只有部分平台需要验证 Token
if (form.value.platform === 'openai') {
// OpenAI 平台必须有 Refresh Token
if (!form.value.refreshToken || form.value.refreshToken.trim() === '') {
errors.value.refreshToken = '请填写 Refresh Token'
hasError = true
}
// Access Token 可选,如果没有会通过 Refresh Token 获取
} else if (form.value.platform === 'gemini') {
// Gemini 平台需要 Access Token
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') {
// Claude 平台需要 Access Token
if (!form.value.accessToken || form.value.accessToken.trim() === '') {
errors.value.accessToken = '请填写 Access Token'
hasError = true
}
}
// Claude Console、CCR、OpenAI-Responses 等其他平台不需要 Token 验证
} else if (form.value.addType === 'apikey') {
// Gemini API 使用单个 apiKey 字段
if (form.value.platform === 'gemini-api') {
if (!form.value.apiKey || form.value.apiKey.trim() === '') {
errors.value.apiKey = '请填写 API Key'
hasError = true
}
// 验证 baseUrl 必须以 /models 结尾
if (!form.value.baseUrl || form.value.baseUrl.trim() === '') {
errors.value.baseUrl = '请填写 API 基础地址'
hasError = true
} else if (!form.value.baseUrl.trim().endsWith('/models')) {
errors.value.baseUrl = 'API 基础地址必须以 /models 结尾'
hasError = true
}
} else {
// 其他平台(如 Droid使用多 API Key 输入
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
}
// 数据同步:确保 groupId 和 groupIds 保持一致 - 创建流程
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') {
// Claude手动模式需要构建claudeAiOauth对象
const expiresInMs = form.value.refreshToken
? 10 * 60 * 1000 // 10分钟
: 365 * 24 * 60 * 60 * 1000 // 1年
// 手动模式也需要确保生成客户端ID
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: [] // 手动添加没有 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.maxConcurrency = form.value.serialQueueEnabled ? 1 : 0
// 添加订阅类型信息
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') {
// Gemini手动模式需要构建geminiOauth对象
const expiresInMs = form.value.refreshToken
? 10 * 60 * 1000 // 10分钟
: 365 * 24 * 60 * 60 * 1000 // 1年
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
}
// 添加 Gemini 优先级
data.priority = form.value.priority || 50
} else if (form.value.platform === 'openai') {
// OpenAI手动模式需要构建openaiOauth对象
const expiresInMs = form.value.refreshToken
? 10 * 60 * 1000 // 10分钟
: 365 * 24 * 60 * 60 * 1000 // 1年
data.openaiOauth = {
idToken: '', // 不再需要用户输入,系统会自动获取
accessToken: form.value.accessToken || '', // Access Token 可选
refreshToken: form.value.refreshToken, // Refresh Token 必填
expires_in: Math.floor(expiresInMs / 1000) // 转换为秒
}
// 账户信息将在首次刷新时自动获取
data.accountInfo = {
accountId: '',
chatgptUserId: '',
organizationId: '',
organizationRole: '',
organizationTitle: '',
planType: '',
email: '',
emailVerified: false
}
// OpenAI 手动模式必须刷新以获取完整信息(包括 ID Token
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') {
// Claude Console 和 CCR 账户特定数据CCR 使用 Claude Console 的后端逻辑)
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
// 如果不启用限流,传递 0 表示不限流
data.rateLimitDuration = form.value.enableRateLimit ? form.value.rateLimitDuration || 60 : 0
// 上游错误处理(仅 Claude Console
if (form.value.platform === 'claude-console') {
data.disableAutoProtection = !!form.value.disableAutoProtection
}
// 额度管理字段
data.dailyQuota = form.value.dailyQuota || 0
data.quotaResetTime = form.value.quotaResetTime || '00:00'
// 并发控制字段
data.maxConcurrentTasks = form.value.maxConcurrentTasks || 0
} else if (form.value.platform === 'openai-responses') {
// 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 // 默认值60不从用户输入获取
data.dailyQuota = form.value.dailyQuota || 0
data.quotaResetTime = form.value.quotaResetTime || '00:00'
} else if (form.value.platform === 'gemini-api') {
// Gemini API 账户特定数据
data.baseUrl = form.value.baseUrl || 'https://generativelanguage.googleapis.com'
data.apiKey = form.value.apiKey
data.priority = form.value.priority || 50
data.supportedModels = Array.isArray(form.value.supportedModels)
? form.value.supportedModels
: []
} else if (form.value.platform === 'bedrock') {
// Bedrock 账户特定数据 - 构造 awsCredentials 对象
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
// 如果不启用限流,传递 0 表示不限流
data.rateLimitDuration = form.value.enableRateLimit ? form.value.rateLimitDuration || 60 : 0
} else if (form.value.platform === 'azure_openai') {
// 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') {
// CCR 使用 Claude Console 的后端 API
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 if (form.value.platform === 'gemini-api') {
result = await accountsStore.createGeminiApiAccount(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}`
}
// 如果有详细的 OAuth 错误信息,也显示出来
if (errorDetails && errorDetails.error_description) {
fullMessage += `\n详细信息: ${errorDetails.error_description}`
} else if (errorDetails && errorDetails.error && errorDetails.error.message) {
// 处理 OpenAI 格式的错误
fullMessage += `\n详细信息: ${errorDetails.error.message}`
}
showToast(fullMessage, 'error', '', 8000)
// 错误已通过 toast 显示给用户
} finally {
loading.value = false
}
}
// 更新账户
const updateAccount = async () => {
// 清除之前的错误
errors.value.name = ''
errors.value.apiKeys = ''
errors.value.baseUrl = ''
// 验证账户名称
if (!form.value.name || form.value.name.trim() === '') {
errors.value.name = '请填写账户名称'
return
}
// Gemini API 的 baseUrl 验证(必须以 /models 结尾)
if (form.value.platform === 'gemini-api') {
const baseUrl = form.value.baseUrl?.trim() || ''
if (!baseUrl) {
errors.value.baseUrl = '请填写 API 基础地址'
return
}
if (!baseUrl.endsWith('/models')) {
errors.value.baseUrl = 'API 基础地址必须以 /models 结尾'
return
}
}
// 分组类型验证 - 更新账户流程修复
if (
form.value.accountType === 'group' &&
(!form.value.groupIds || form.value.groupIds.length === 0)
) {
showToast('请选择一个分组', 'error')
return
}
// 数据同步:确保 groupId 和 groupIds 保持一致 - 更新流程
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 = ''
}
}
// 对于Gemini账户检查项目 ID
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
}
// 只有非空时才更新token
if (form.value.accessToken || form.value.refreshToken) {
const trimmedAccessToken = form.value.accessToken?.trim() || ''
const trimmedRefreshToken = form.value.refreshToken?.trim() || ''
if (props.account.platform === 'claude') {
// Claude需要构建claudeAiOauth对象
const expiresInMs = form.value.refreshToken
? 10 * 60 * 1000 // 10分钟
: 365 * 24 * 60 * 60 * 1000 // 1年
data.claudeAiOauth = {
accessToken: trimmedAccessToken || '',
refreshToken: trimmedRefreshToken || '',
expiresAt: Date.now() + expiresInMs,
scopes: props.account.scopes || [] // 保持原有的 scopes如果没有则为空数组
}
} else if (props.account.platform === 'gemini') {
// Gemini需要构建geminiOauth对象
const expiresInMs = form.value.refreshToken
? 10 * 60 * 1000 // 10分钟
: 365 * 24 * 60 * 60 * 1000 // 1年
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') {
// OpenAI需要构建openaiOauth对象
const expiresInMs = form.value.refreshToken
? 10 * 60 * 1000 // 10分钟
: 365 * 24 * 60 * 60 * 1000 // 1年
data.openaiOauth = {
idToken: '', // 不需要用户输入
accessToken: trimmedAccessToken || '',
refreshToken: trimmedRefreshToken || '',
expires_in: Math.floor(expiresInMs / 1000) // 转换为秒
}
// 编辑 OpenAI 账户时,如果更新了 Refresh Token也需要验证
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'
}
// Claude 官方账号优先级和订阅类型更新
if (props.account.platform === 'claude') {
// 更新模式也需要确保生成客户端ID
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.maxConcurrency = form.value.serialQueueEnabled ? 1 : 0
// 更新订阅类型信息
data.subscriptionInfo = {
accountType: form.value.subscriptionType || 'claude_max',
hasClaudeMax: form.value.subscriptionType === 'claude_max',
hasClaudePro: form.value.subscriptionType === 'claude_pro',
manuallySet: true // 标记为手动设置
}
}
// OpenAI 账号优先级更新
if (props.account.platform === 'openai') {
data.priority = form.value.priority || 50
}
// Gemini 账号优先级更新
if (props.account.platform === 'gemini') {
data.priority = form.value.priority || 50
}
// Claude Console 特定更新
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
// 如果不启用限流,传递 0 表示不限流
data.rateLimitDuration = form.value.enableRateLimit ? form.value.rateLimitDuration || 60 : 0
// 上游错误处理
data.disableAutoProtection = !!form.value.disableAutoProtection
// 额度管理字段
data.dailyQuota = form.value.dailyQuota || 0
data.quotaResetTime = form.value.quotaResetTime || '00:00'
// 并发控制字段
data.maxConcurrentTasks = form.value.maxConcurrentTasks || 0
}
// OpenAI-Responses 特定更新
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
// 编辑时不上传 rateLimitDuration保持原值
data.dailyQuota = form.value.dailyQuota || 0
data.quotaResetTime = form.value.quotaResetTime || '00:00'
}
// Bedrock 特定更新
if (props.account.platform === 'bedrock') {
// 只有当有凭证变更时才构造 awsCredentials 对象
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
// 如果不启用限流,传递 0 表示不限流
data.rateLimitDuration = form.value.enableRateLimit ? form.value.rateLimitDuration || 60 : 0
}
// Azure OpenAI 特定更新
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
// 只有当有新的 API Key 时才更新
if (form.value.apiKey && form.value.apiKey.trim()) {
data.apiKey = form.value.apiKey
}
}
// Gemini API 特定更新
if (props.account.platform === 'gemini-api') {
data.baseUrl = form.value.baseUrl || 'https://generativelanguage.googleapis.com'
// 只有当有新的 API Key 时才更新
if (form.value.apiKey && form.value.apiKey.trim()) {
data.apiKey = form.value.apiKey
}
data.priority = form.value.priority || 50
data.supportedModels = Array.isArray(form.value.supportedModels)
? form.value.supportedModels
: []
}
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 === 'gemini-api') {
await accountsStore.updateGeminiApiAccount(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}`
}
// 如果有详细的 OAuth 错误信息,也显示出来
if (errorDetails && errorDetails.error_description) {
fullMessage += `\n详细信息: ${errorDetails.error_description}`
} else if (errorDetails && errorDetails.error && errorDetails.error.message) {
// 处理 OpenAI 格式的错误
fullMessage += `\n详细信息: ${errorDetails.error.message}`
}
showToast(fullMessage, 'error', '', 8000)
// 错误已通过 toast 显示给用户
} finally {
loading.value = false
}
}
// 监听表单名称变化,清除错误
watch(
() => form.value.name,
() => {
if (errors.value.name && form.value.name?.trim()) {
errors.value.name = ''
}
}
)
// 监听Access Token变化清除错误
watch(
() => form.value.accessToken,
() => {
if (errors.value.accessToken && form.value.accessToken?.trim()) {
errors.value.accessToken = ''
}
}
)
// 监听Refresh Token变化清除错误
watch(
() => form.value.refreshToken,
() => {
if (errors.value.refreshToken && form.value.refreshToken?.trim()) {
errors.value.refreshToken = ''
}
}
)
// 监听API URL变化清除错误
watch(
() => form.value.apiUrl,
() => {
if (errors.value.apiUrl && form.value.apiUrl?.trim()) {
errors.value.apiUrl = ''
}
}
)
// 监听API Key变化清除错误
watch(
() => form.value.apiKey,
() => {
if (errors.value.apiKey && form.value.apiKey?.trim()) {
errors.value.apiKey = ''
}
}
)
// 监听Azure Endpoint变化清除错误
watch(
() => form.value.azureEndpoint,
() => {
if (errors.value.azureEndpoint && form.value.azureEndpoint?.trim()) {
errors.value.azureEndpoint = ''
}
}
)
// 监听Deployment Name变化清除错误
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
// Claude Console 和 CCR 使用 Claude 分组
if (form.value.platform === 'claude-console' || form.value.platform === 'ccr') {
platformFilter = 'claude'
}
// OpenAI-Responses 使用 OpenAI 分组
else if (form.value.platform === 'openai-responses') {
platformFilter = 'openai'
}
// Gemini-API 使用 Gemini 分组
else if (form.value.platform === 'gemini-api') {
platformFilter = 'gemini'
}
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()
}
// 处理 API Key 管理模态框刷新
const handleApiKeyRefresh = async () => {
// 刷新账户信息以更新 API Key 数量
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' // Claude Console、CCR、Bedrock 和 OpenAI-Responses 只支持手动模式
} else if (newPlatform === 'claude') {
// 切换到 Claude 时,使用 oauth 作为默认方式
form.value.addType = 'oauth'
} else if (newPlatform === 'gemini') {
// 切换到 Gemini 时,使用 OAuth 作为默认方式
form.value.addType = 'oauth'
} else if (newPlatform === 'openai') {
// 切换到 OpenAI 时,使用 OAuth 作为默认方式
form.value.addType = 'oauth'
} else if (newPlatform === 'gemini-api' || newPlatform === 'azure_openai') {
// 切换到 Gemini API 或 Azure OpenAI 时,使用 apikey 模式(直接创建,不需要 OAuth 流程)
form.value.addType = 'apikey'
}
// 平台变化时,清空分组选择
if (form.value.accountType === 'group') {
form.value.groupId = ''
form.value.groupIds = []
}
}
)
// 监听分组选择变化,保持 groupId 和 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') {
// 切换到 API Key 模式时清理 Token 字段
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') {
// 切换离开 API Key 模式时重置 API Key 输入
form.value.apiKeysInput = ''
form.value.apiKeyUpdateMode = 'append'
errors.value.apiKeys = ''
if (!isEdit.value) {
form.value.authenticationMethod = ''
}
}
}
)
// 监听 API Key 更新模式切换,自动清理提示
watch(
() => form.value.apiKeyUpdateMode,
(newMode, oldMode) => {
if (newMode === oldMode) {
return
}
if (errors.value.apiKeys) {
errors.value.apiKeys = ''
}
}
)
// 监听 API Key 输入,自动清理错误提示
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 = ''
}
}
)
// 监听Setup Token授权码输入自动提取URL中的code参数
watch(setupTokenAuthCode, (newValue) => {
if (!newValue || typeof newValue !== 'string') return
const trimmedValue = newValue.trim()
// 如果内容为空,不处理
if (!trimmedValue) return
// 检查是否是 URL 格式(包含 http:// 或 https://
const isUrl = trimmedValue.startsWith('http://') || trimmedValue.startsWith('https://')
// 如果是 URL 格式
if (isUrl) {
// 检查是否是正确的 localhost:45462 开头的 URL
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')
// Successfully extracted authorization code from URL
} else {
// URL 中没有 code 参数
showToast('URL 中未找到授权码参数,请检查链接是否正确', 'error')
}
} catch (error) {
// URL 解析失败
// Failed to parse URL
showToast('链接格式错误,请检查是否为完整的 URL', 'error')
}
} else {
// 错误的 URL不是 localhost:45462 开头)
showToast('请粘贴以 http://localhost:45462 开头的链接', 'error')
}
}
// 如果不是 URL保持原值兼容直接输入授权码
})
// 监听平台变化
watch(
() => form.value.platform,
(newPlatform) => {
// 当选择 CCR 平台时,通知父组件
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'
// 获取分组ID - 可能来自 groupId 字段或 groupInfo 对象
let groupId = ''
if (newAccount.accountType === 'group') {
groupId = newAccount.groupId || (newAccount.groupInfo && newAccount.groupInfo.id) || ''
}
// 初始化订阅类型(从 subscriptionInfo 中提取,兼容旧数据默认为 claude_max
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 || '',
serialQueueEnabled: (newAccount.maxConcurrency || 0) > 0,
groupId: groupId,
groupIds: [],
projectId: newAccount.projectId || '',
accessToken: '',
refreshToken: '',
authenticationMethod: newAccount.authenticationMethod || '',
apiKeysInput: '',
apiKeyUpdateMode: 'append',
proxy: proxyConfig,
// Claude Console 特定字段
apiUrl: newAccount.apiUrl || '',
apiKey: '', // 编辑模式不显示现有的 API Key
priority: newAccount.priority || 50,
supportedModels: (() => {
const models = newAccount.supportedModels
if (!models) return []
// 处理对象格式Claude Console 的新格式)
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,
// Bedrock 特定字段
accessKeyId: '', // 编辑模式不显示现有的访问密钥
secretAccessKey: '', // 编辑模式不显示现有的秘密密钥
region: newAccount.region || '',
sessionToken: '', // 编辑模式不显示现有的会话令牌
defaultModel: newAccount.defaultModel || '',
smallFastModel: newAccount.smallFastModel || '',
// Azure OpenAI 特定字段
azureEndpoint: newAccount.azureEndpoint || '',
apiVersion: newAccount.apiVersion || '',
deploymentName: newAccount.deploymentName || '',
// OpenAI-Responses 特定字段
baseApi: newAccount.baseApi || '',
// Gemini-API 特定字段
baseUrl: newAccount.baseUrl || 'https://generativelanguage.googleapis.com',
// 额度管理字段
dailyQuota: newAccount.dailyQuota || 0,
dailyUsage: newAccount.dailyUsage || 0,
quotaResetTime: newAccount.quotaResetTime || '00:00',
// 并发控制字段
maxConcurrentTasks: newAccount.maxConcurrentTasks || 0,
// 上游错误处理
disableAutoProtection: newAccount.disableAutoProtection === true
}
// 如果是Claude Console账户加载实时使用情况
if (newAccount.platform === 'claude-console') {
loadAccountUsage()
}
// 如果是分组类型加载分组ID
if (newAccount.accountType === 'group') {
// 先加载分组列表
loadGroups().then(async () => {
const foundGroupIds = []
// 优先使用 groupInfos 数组(后端返回的标准格式)
if (
newAccount.groupInfos &&
Array.isArray(newAccount.groupInfos) &&
newAccount.groupInfos.length > 0
) {
// 从 groupInfos 数组中提取所有分组 ID
newAccount.groupInfos.forEach((group) => {
if (group && group.id) {
foundGroupIds.push(group.id)
}
})
if (foundGroupIds.length > 0) {
form.value.groupId = foundGroupIds[0]
}
} else if (newAccount.groupInfo && newAccount.groupInfo.id) {
// 兼容旧的 groupInfo 单对象格式
form.value.groupId = newAccount.groupInfo.id
foundGroupIds.push(newAccount.groupInfo.id)
} else if (newAccount.groupId) {
// 如果账户有 groupId 字段,直接使用
form.value.groupId = newAccount.groupId
foundGroupIds.push(newAccount.groupId)
} else if (
newAccount.groupIds &&
Array.isArray(newAccount.groupIds) &&
newAccount.groupIds.length > 0
) {
// 如果账户有 groupIds 数组,使用它
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 }
)
// 获取统一 User-Agent 信息
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) {
// Failed to fetch unified User-Agent
unifiedUserAgent.value = ''
}
}
// 清除统一 User-Agent 缓存
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) {
// Failed to clear unified User-Agent cache
showToast('清除缓存失败:' + (error.message || '未知错误'), 'error')
} finally {
clearingCache.value = false
}
}
// 生成客户端标识
const generateClientId = () => {
// 生成64位十六进制字符串32字节
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) {
// 如果启用了统一客户端标识自动启用统一User-Agent
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'
})
}
// 组件挂载时获取统一 User-Agent 信息
onMounted(() => {
// 初始化平台分组
platformGroup.value = determinePlatformGroup(form.value.platform)
// 初始化模型映射表(如果是编辑模式)
if (isEdit.value) {
initModelMappings()
}
// 获取Claude Code统一User-Agent信息
fetchUnifiedUserAgent()
// 如果是编辑模式且是Claude Console账户加载使用情况
if (isEdit.value && props.account?.platform === 'claude-console') {
loadAccountUsage()
}
})
// 监听平台变化当切换到Claude平台时获取统一User-Agent信息
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>