Files
claude-relay-service/web/admin-spa/src/components/accounts/AccountForm.vue
2025-08-10 17:46:31 +09:00

2210 lines
83 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 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 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 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">平台</label>
<div class="flex gap-4">
<label class="flex cursor-pointer items-center">
<input v-model="form.platform" class="mr-2" type="radio" value="claude" />
<span class="text-sm text-gray-700">Claude</span>
</label>
<label class="flex cursor-pointer items-center">
<input v-model="form.platform" class="mr-2" type="radio" value="claude-console" />
<span class="text-sm text-gray-700">Claude Console</span>
</label>
<label class="flex cursor-pointer items-center">
<input v-model="form.platform" class="mr-2" type="radio" value="gemini" />
<span class="text-sm text-gray-700">Gemini</span>
</label>
<label class="flex cursor-pointer items-center">
<input v-model="form.platform" class="mr-2" type="radio" value="openai" />
<span class="text-sm text-gray-700">OpenAI</span>
</label>
<label class="flex cursor-pointer items-center">
<input v-model="form.platform" class="mr-2" type="radio" value="bedrock" />
<span class="text-sm text-gray-700">Bedrock</span>
</label>
</div>
</div>
<div
v-if="!isEdit && form.platform !== 'claude-console' && form.platform !== 'bedrock'"
>
<label class="mb-3 block text-sm font-semibold text-gray-700">添加方式</label>
<div class="flex flex-wrap gap-4">
<label v-if="form.platform === 'claude'" class="flex cursor-pointer items-center">
<input v-model="form.addType" class="mr-2" type="radio" value="setup-token" />
<span class="text-sm text-gray-700">Setup Token (推荐)</span>
</label>
<label class="flex cursor-pointer items-center">
<input v-model="form.addType" class="mr-2" type="radio" value="oauth" />
<span class="text-sm text-gray-700">OAuth 授权</span>
</label>
<label class="flex cursor-pointer items-center">
<input v-model="form.addType" class="mr-2" type="radio" value="manual" />
<span class="text-sm text-gray-700">手动输入 Access Token</span>
</label>
</div>
</div>
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700">账户名称</label>
<input
v-model="form.name"
class="form-input w-full"
: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">描述 (可选)</label>
<textarea
v-model="form.description"
class="form-input w-full resize-none"
placeholder="账户用途说明..."
rows="3"
/>
</div>
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700">账户类型</label>
<div class="flex gap-4">
<label class="flex cursor-pointer items-center">
<input v-model="form.accountType" class="mr-2" type="radio" value="shared" />
<span class="text-sm text-gray-700">共享账户</span>
</label>
<label class="flex cursor-pointer items-center">
<input v-model="form.accountType" class="mr-2" type="radio" value="dedicated" />
<span class="text-sm text-gray-700">专属账户</span>
</label>
<label class="flex cursor-pointer items-center">
<input v-model="form.accountType" class="mr-2" type="radio" value="group" />
<span class="text-sm text-gray-700">分组调度</span>
</label>
</div>
<p class="mt-2 text-xs text-gray-500">
共享账户供所有API Key使用专属账户仅供特定API
Key使用分组调度加入分组供分组内调度
</p>
</div>
<!-- 分组选择器 -->
<div v-if="form.accountType === 'group'">
<label class="mb-3 block text-sm font-semibold text-gray-700">选择分组 *</label>
<div class="flex gap-2">
<select v-model="form.groupId" class="form-input flex-1" required>
<option value="">请选择分组</option>
<option v-for="group in filteredGroups" :key="group.id" :value="group.id">
{{ group.name }} ({{ group.memberCount || 0 }} 个成员)
</option>
<option value="__new__">+ 新建分组</option>
</select>
<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"
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">项目 ID (可选)</label>
<input
v-model="form.projectId"
class="form-input w-full"
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"
>AWS 访问密钥 ID *</label
>
<input
v-model="form.accessKeyId"
class="form-input w-full"
: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"
>AWS 秘密访问密钥 *</label
>
<input
v-model="form.secretAccessKey"
class="form-input w-full"
: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">AWS 区域 *</label>
<input
v-model="form.region"
class="form-input w-full"
: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"
>会话令牌 (可选)</label
>
<input
v-model="form.sessionToken"
class="form-input w-full"
placeholder="如果使用临时凭证,请输入会话令牌"
type="password"
/>
<p class="mt-1 text-xs text-gray-500">仅在使用临时 AWS 凭证时需要填写</p>
</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 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"
>小快速模型 (可选)</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
>
<input
v-model.number="form.rateLimitDuration"
class="form-input w-full"
min="1"
placeholder="默认60分钟"
type="number"
/>
<p class="mt-1 text-xs text-gray-500">
当账号返回429错误时暂停调度的时间分钟
</p>
</div>
</div>
<!-- Claude Console 特定字段 -->
<div v-if="form.platform === 'claude-console' && !isEdit" 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"
: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">API Key *</label>
<input
v-model="form.apiKey"
class="form-input w-full"
: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>
<label class="mb-3 block text-sm font-semibold text-gray-700"
>模型映射表 (可选)</label
>
<div class="mb-3 rounded-lg bg-blue-50 p-3">
<p class="text-xs text-blue-700">
<i class="fas fa-info-circle mr-1" />
留空表示支持所有模型且不修改请求配置映射后左侧模型会被识别为支持的模型右侧是实际发送的模型
</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"
placeholder="原始模型名称"
type="text"
/>
<i class="fas fa-arrow-right text-gray-400" />
<input
v-model="mapping.to"
class="form-input flex-1"
placeholder="映射后的模型名称"
type="text"
/>
<button
class="rounded-lg p-2 text-red-500 transition-colors hover:bg-red-50"
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"
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"
type="button"
@click="
addPresetMapping('claude-sonnet-4-20250514', 'claude-sonnet-4-20250514')
"
>
+ Sonnet 4
</button>
<button
class="rounded-lg bg-purple-100 px-3 py-1 text-xs text-purple-700 transition-colors hover:bg-purple-200"
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"
type="button"
@click="
addPresetMapping('claude-3-5-haiku-20241022', 'claude-3-5-haiku-20241022')
"
>
+ Haiku 3.5
</button>
<button
class="rounded-lg bg-orange-100 px-3 py-1 text-xs text-orange-700 transition-colors hover:bg-orange-200"
type="button"
@click="
addPresetMapping('claude-opus-4-1-20250805', 'claude-sonnet-4-20250514')
"
>
+ Opus 4.1 Sonnet 4
</button>
</div>
<p class="mt-1 text-xs text-gray-500">
留空表示支持所有模型如果指定模型请求中的模型不在列表内将不会调度到此账号
</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>
<div>
<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"
placeholder="默认60分钟"
type="number"
/>
<p class="mt-1 text-xs text-gray-500">
当账号返回429错误时暂停调度的时间分钟
</p>
</div>
</div>
<!-- ClaudeClaude Console和Bedrock的优先级设置 -->
<div
v-if="
form.platform === 'claude' ||
form.platform === 'claude-console' ||
form.platform === 'bedrock'
"
>
<label class="mb-3 block text-sm font-semibold text-gray-700"
>调度优先级 (1-100)</label
>
<input
v-model.number="form.priority"
class="form-input w-full"
max="100"
min="1"
placeholder="数字越小优先级越高默认50"
type="number"
/>
<p class="mt-1 text-xs text-gray-500">数字越小优先级越高建议范围1-100</p>
</div>
<!-- 手动输入 Token 字段 -->
<div
v-if="
form.addType === 'manual' &&
form.platform !== 'claude-console' &&
form.platform !== 'bedrock'
"
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">手动输入 Token</h5>
<p v-if="form.platform === 'claude'" class="mb-2 text-sm text-blue-800">
请输入有效的 Claude Access Token。如果您有 Refresh
Token建议也一并填写以支持自动刷新。
</p>
<p v-else-if="form.platform === 'gemini'" class="mb-2 text-sm text-blue-800">
请输入有效的 Gemini Access Token。如果您有 Refresh
Token建议也一并填写以支持自动刷新。
</p>
<p v-else-if="form.platform === 'openai'" class="mb-2 text-sm text-blue-800">
请输入有效的 OpenAI Access Token。如果您有 Refresh
Token建议也一并填写以支持自动刷新。
</p>
<div class="mb-2 mt-2 rounded-lg border border-blue-300 bg-white/80 p-3">
<p class="mb-1 text-sm font-medium text-blue-900">
<i class="fas fa-folder-open mr-1" />
获取 Access Token 的方法:
</p>
<p v-if="form.platform === 'claude'" class="text-xs text-blue-800">
请从已登录 Claude Code 的机器上获取
<code class="rounded bg-blue-100 px-1 py-0.5 font-mono"
>~/.claude/.credentials.json</code
>
文件中的凭证, 请勿使用 Claude 官网 API Keys 页面的密钥。
</p>
<p v-else-if="form.platform === 'gemini'" class="text-xs text-blue-800">
请从已登录 Gemini CLI 的机器上获取
<code class="rounded bg-blue-100 px-1 py-0.5 font-mono"
>~/.config/gemini/credentials.json</code
>
文件中的凭证。
</p>
<p v-else-if="form.platform === 'openai'" class="text-xs text-blue-800">
请从已登录 OpenAI 账户的机器上获取认证凭证, 或通过 OAuth 授权流程获取 Access
Token。
</p>
</div>
<p class="text-xs text-blue-600">
💡 如果未填写 Refresh TokenToken 过期后需要手动更新。
</p>
</div>
</div>
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700">Access Token *</label>
<textarea
v-model="form.accessToken"
class="form-input w-full resize-none font-mono text-xs"
: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>
<label class="mb-3 block text-sm font-semibold text-gray-700"
>Refresh Token (可选)</label
>
<textarea
v-model="form.refreshToken"
class="form-input w-full resize-none font-mono text-xs"
placeholder="请输入 Refresh Token..."
rows="4"
/>
</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"
type="button"
@click="$emit('close')"
>
取消
</button>
<button
v-if="
(form.addType === 'oauth' || form.addType === 'setup-token') &&
form.platform !== 'claude-console' &&
form.platform !== 'bedrock'
"
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'"
: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">
<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">Claude Setup Token 授权</h4>
<p class="mb-4 text-sm text-blue-800">
请按照以下步骤通过 Setup Token 完成 Claude 账户的授权:
</p>
<div class="space-y-4">
<!-- 步骤1: 生成授权链接 -->
<div class="rounded-lg border border-blue-300 bg-white/80 p-4">
<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">点击下方按钮生成授权链接</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"
readonly
type="text"
:value="setupTokenAuthUrl"
/>
<button
class="rounded-lg bg-gray-100 px-3 py-2 transition-colors hover:bg-gray-200"
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">
<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">在浏览器中打开链接并完成授权</p>
<p class="mb-2 text-sm text-blue-700">
请在新标签页中打开授权链接,登录您的 Claude 账户并授权 Claude Code。
</p>
<div class="rounded border border-yellow-300 bg-yellow-50 p-3">
<p class="text-xs text-yellow-800">
<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">
<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">输入 Authorization Code</p>
<p class="mb-3 text-sm text-blue-700">
授权完成后,从返回页面复制 Authorization Code并粘贴到下方输入框
</p>
<div class="space-y-3">
<div>
<label class="mb-2 block text-sm font-semibold text-gray-700">
<i class="fas fa-key mr-2 text-blue-500" />Authorization Code
</label>
<textarea
v-model="setupTokenAuthCode"
class="form-input w-full resize-none font-mono text-sm"
placeholder="粘贴从Claude Code授权页面获取的Authorization Code..."
rows="3"
/>
</div>
<p class="mt-2 text-xs text-gray-500">
<i class="fas fa-info-circle mr-1" />
请粘贴从Claude Code授权页面复制的Authorization Code
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="flex gap-3 pt-4">
<button
class="flex-1 rounded-xl bg-gray-100 px-6 py-3 font-semibold text-gray-700 transition-colors hover:bg-gray-200"
type="button"
@click="oauthStep = 1"
>
上一步
</button>
<button
class="btn btn-primary flex-1 px-6 py-3 font-semibold"
:disabled="!canExchangeSetupToken || setupTokenExchanging"
type="button"
@click="exchangeSetupTokenCode"
>
<div v-if="setupTokenExchanging" class="loading-spinner mr-2" />
{{ setupTokenExchanging ? '验证中...' : '完成授权' }}
</button>
</div>
</div>
<!-- 编辑模式 -->
<div v-if="isEdit" class="space-y-6">
<!-- 基本信息 -->
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700">账户名称</label>
<input
v-model="form.name"
class="form-input w-full"
placeholder="为账户设置一个易识别的名称"
required
type="text"
/>
</div>
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700">描述 (可选)</label>
<textarea
v-model="form.description"
class="form-input w-full resize-none"
placeholder="账户用途说明..."
rows="3"
/>
</div>
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700">账户类型</label>
<div class="flex gap-4">
<label class="flex cursor-pointer items-center">
<input v-model="form.accountType" class="mr-2" type="radio" value="shared" />
<span class="text-sm text-gray-700">共享账户</span>
</label>
<label class="flex cursor-pointer items-center">
<input v-model="form.accountType" class="mr-2" type="radio" value="dedicated" />
<span class="text-sm text-gray-700">专属账户</span>
</label>
<label class="flex cursor-pointer items-center">
<input v-model="form.accountType" class="mr-2" type="radio" value="group" />
<span class="text-sm text-gray-700">分组调度</span>
</label>
</div>
<p class="mt-2 text-xs text-gray-500">
共享账户供所有API Key使用专属账户仅供特定API
Key使用分组调度加入分组供分组内调度
</p>
</div>
<!-- 分组选择器 -->
<div v-if="form.accountType === 'group'">
<label class="mb-3 block text-sm font-semibold text-gray-700">选择分组 *</label>
<div class="flex gap-2">
<select v-model="form.groupId" class="form-input flex-1" required>
<option value="">请选择分组</option>
<option v-for="group in filteredGroups" :key="group.id" :value="group.id">
{{ group.name }} ({{ group.memberCount || 0 }} 个成员)
</option>
<option value="__new__">+ 新建分组</option>
</select>
<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"
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">项目 ID (可选)</label>
<input
v-model="form.projectId"
class="form-input w-full"
placeholder="例如verdant-wares-464411-k9"
type="text"
/>
<p class="mt-2 text-xs text-gray-500">Google Cloud/Workspace 账号可能需要提供项目 ID</p>
</div>
<!-- Claude、Claude Console和Bedrock的优先级设置编辑模式 -->
<div
v-if="
form.platform === 'claude' ||
form.platform === 'claude-console' ||
form.platform === 'bedrock'
"
>
<label class="mb-3 block text-sm font-semibold text-gray-700">调度优先级 (1-100)</label>
<input
v-model.number="form.priority"
class="form-input w-full"
max="100"
min="1"
placeholder="数字越小优先级越高"
type="number"
/>
<p class="mt-1 text-xs text-gray-500">数字越小优先级越高建议范围1-100</p>
</div>
<!-- Claude Console 特定字段(编辑模式)-->
<div v-if="form.platform === 'claude-console'" 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>
<label class="mb-3 block text-sm font-semibold text-gray-700"
>模型映射表 (可选)</label
>
<div class="mb-3 rounded-lg bg-blue-50 p-3">
<p class="text-xs text-blue-700">
<i class="fas fa-info-circle mr-1" />
留空表示支持所有模型且不修改请求。配置映射后,左侧模型会被识别为支持的模型,右侧是实际发送的模型。
</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"
placeholder="原始模型名称"
type="text"
/>
<i class="fas fa-arrow-right text-gray-400" />
<input
v-model="mapping.to"
class="form-input flex-1"
placeholder="映射后的模型名称"
type="text"
/>
<button
class="rounded-lg p-2 text-red-500 transition-colors hover:bg-red-50"
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"
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"
type="button"
@click="addPresetMapping('claude-sonnet-4-20250514', 'claude-sonnet-4-20250514')"
>
+ Sonnet 4
</button>
<button
class="rounded-lg bg-purple-100 px-3 py-1 text-xs text-purple-700 transition-colors hover:bg-purple-200"
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"
type="button"
@click="
addPresetMapping('claude-3-5-haiku-20241022', 'claude-3-5-haiku-20241022')
"
>
+ Haiku 3.5
</button>
<button
class="rounded-lg bg-orange-100 px-3 py-1 text-xs text-orange-700 transition-colors hover:bg-orange-200"
type="button"
@click="addPresetMapping('claude-opus-4-1-20250805', 'claude-sonnet-4-20250514')"
>
+ Opus 4.1 → Sonnet 4
</button>
</div>
<p class="mt-1 text-xs text-gray-500">
留空表示支持所有模型。如果指定模型,请求中的模型不在列表内将不会调度到此账号
</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>
<div>
<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"
/>
</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"
>小快速模型 (可选)</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>
<input
v-model.number="form.rateLimitDuration"
class="form-input w-full"
min="1"
type="number"
/>
</div>
</div>
<!-- Token 更新 -->
<div
v-if="form.platform !== 'claude-console' && form.platform !== 'bedrock'"
class="rounded-lg border border-amber-200 bg-amber-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-amber-500"
>
<i class="fas fa-key text-sm text-white" />
</div>
<div>
<h5 class="mb-2 font-semibold text-amber-900">更新 Token</h5>
<p class="mb-2 text-sm text-amber-800">
可以更新 Access Token 和 Refresh Token。为了安全起见不会显示当前的 Token 值。
</p>
<p class="text-xs text-amber-600">💡 留空表示不更新该字段。</p>
</div>
</div>
<div class="space-y-4">
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700"
>新的 Access Token</label
>
<textarea
v-model="form.accessToken"
class="form-input w-full resize-none font-mono text-xs"
placeholder="留空表示不更新..."
rows="4"
/>
</div>
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700"
>新的 Refresh Token</label
>
<textarea
v-model="form.refreshToken"
class="form-input w-full resize-none font-mono text-xs"
placeholder="留空表示不更新..."
rows="4"
/>
</div>
</div>
</div>
<!-- 代理设置 -->
<ProxyConfig v-model="form.proxy" />
<div class="flex gap-3 pt-4">
<button
class="flex-1 rounded-xl bg-gray-100 px-6 py-3 font-semibold text-gray-700 transition-colors hover:bg-gray-200"
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"
/>
</Teleport>
</template>
<script setup>
import { ref, computed, watch } 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'
const props = defineProps({
account: {
type: Object,
default: null
}
})
const emit = defineEmits(['close', 'success'])
const accountsStore = useAccountsStore()
const { showConfirmModal, confirmOptions, showConfirm, handleConfirm, handleCancel } = useConfirm()
// 是否为编辑模式
const isEdit = computed(() => !!props.account)
const show = ref(true)
// OAuth步骤
const oauthStep = ref(1)
const loading = 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('')
// 初始化代理配置
const initProxyConfig = () => {
if (props.account?.proxy && props.account.proxy.host && props.account.proxy.port) {
return {
enabled: true,
type: props.account.proxy.type || 'socks5',
host: props.account.proxy.host,
port: props.account.proxy.port,
username: props.account.proxy.username || '',
password: props.account.proxy.password || ''
}
}
return {
enabled: false,
type: 'socks5',
host: '',
port: '',
username: '',
password: ''
}
}
// 表单数据
const form = ref({
platform: props.account?.platform || 'claude',
addType: props.account?.platform === 'gemini' ? 'oauth' : 'setup-token',
name: props.account?.name || '',
description: props.account?.description || '',
accountType: props.account?.accountType || 'shared',
groupId: '',
projectId: props.account?.projectId || '',
accessToken: '',
refreshToken: '',
proxy: initProxyConfig(),
// Claude Console 特定字段
apiUrl: props.account?.apiUrl || '',
apiKey: props.account?.apiKey || '',
priority: props.account?.priority || 50,
supportedModels: (() => {
const models = props.account?.supportedModels
if (!models) return ''
// 处理对象格式Claude Console 的新格式)
if (typeof models === 'object' && !Array.isArray(models)) {
return Object.keys(models).join('\n')
}
// 处理数组格式(向后兼容)
if (Array.isArray(models)) {
return models.join('\n')
}
return ''
})(),
userAgent: props.account?.userAgent || '',
rateLimitDuration: props.account?.rateLimitDuration || 60,
// 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 || ''
})
// 模型映射表数据
const modelMappings = ref([])
// 初始化模型映射表
const initModelMappings = () => {
if (props.account?.supportedModels) {
// 如果是对象格式(新的映射表)
if (
typeof props.account.supportedModels === 'object' &&
!Array.isArray(props.account.supportedModels)
) {
modelMappings.value = Object.entries(props.account.supportedModels).map(([from, to]) => ({
from,
to
}))
} else if (Array.isArray(props.account.supportedModels)) {
// 如果是数组格式(旧格式),转换为映射表
modelMappings.value = props.account.supportedModels.map((model) => ({
from: model,
to: model
}))
}
}
}
// 表单验证错误
const errors = ref({
name: '',
accessToken: '',
apiUrl: '',
apiKey: '',
accessKeyId: '',
secretAccessKey: '',
region: ''
})
// 计算是否可以进入下一步
const canProceed = computed(() => {
return form.value.name?.trim() && form.value.platform
})
// 计算是否可以交换Setup Token code
const canExchangeSetupToken = computed(() => {
return setupTokenAuthUrl.value && setupTokenAuthCode.value.trim()
})
// // 计算是否可以创建
// const canCreate = computed(() => {
// if (form.value.addType === 'manual') {
// return form.value.name?.trim() && form.value.accessToken?.trim()
// }
// return form.value.name?.trim()
// })
// 下一步
const nextStep = async () => {
// 清除之前的错误
errors.value.name = ''
if (!canProceed.value) {
if (!form.value.name || form.value.name.trim() === '') {
errors.value.name = '请填写账户名称'
}
return
}
// 分组类型验证
if (
form.value.accountType === 'group' &&
(!form.value.groupId || form.value.groupId.trim() === '')
) {
showToast('请选择一个分组', 'error')
return
}
// 对于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 proxyConfig = form.value.proxy?.enabled
? {
proxy: {
type: form.value.proxy.type,
host: form.value.proxy.host,
port: parseInt(form.value.proxy.port),
username: form.value.proxy.username || null,
password: form.value.proxy.password || null
}
}
: {}
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()
}
// 添加代理配置(如果启用)
if (form.value.proxy?.enabled) {
data.proxy = {
type: form.value.proxy.type,
host: form.value.proxy.host,
port: parseInt(form.value.proxy.port),
username: form.value.proxy.username || null,
password: form.value.proxy.password || null
}
}
const tokenInfo = await accountsStore.exchangeClaudeSetupTokenCode(data)
// 调用相同的成功处理函数
await handleOAuthSuccess(tokenInfo)
} catch (error) {
showToast(error.message || 'Setup Token授权失败请检查授权码是否正确', 'error')
} finally {
setupTokenExchanging.value = false
}
}
// 处理OAuth成功
const handleOAuthSuccess = async (tokenInfo) => {
loading.value = true
try {
const data = {
name: form.value.name,
description: form.value.description,
accountType: form.value.accountType,
groupId: form.value.accountType === 'group' ? form.value.groupId : undefined,
proxy: form.value.proxy.enabled
? {
type: form.value.proxy.type,
host: form.value.proxy.host,
port: parseInt(form.value.proxy.port),
username: form.value.proxy.username || null,
password: form.value.proxy.password || null
}
: null
}
if (form.value.platform === 'claude') {
// Claude使用claudeAiOauth字段
data.claudeAiOauth = tokenInfo.claudeAiOauth || tokenInfo
data.priority = form.value.priority || 50
} else if (form.value.platform === 'gemini') {
// Gemini使用geminiOauth字段
data.geminiOauth = tokenInfo.tokens || tokenInfo
if (form.value.projectId) {
data.projectId = form.value.projectId
}
} else if (form.value.platform === 'openai') {
data.openaiOauth = tokenInfo.tokens || tokenInfo
data.accountInfo = tokenInfo.accountInfo
}
let result
if (form.value.platform === 'claude') {
result = await accountsStore.createClaudeAccount(data)
} else if (form.value.platform === 'openai') {
result = await accountsStore.createOpenAIAccount(data)
} else {
result = await accountsStore.createGeminiAccount(data)
}
emit('success', result)
} catch (error) {
showToast(error.message || '账户创建失败', 'error')
} finally {
loading.value = false
}
}
// 创建账户(手动模式)
const createAccount = async () => {
// 清除之前的错误
errors.value.name = ''
errors.value.accessToken = ''
errors.value.apiUrl = ''
errors.value.apiKey = ''
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
}
} 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.addType === 'manual' &&
(!form.value.accessToken || form.value.accessToken.trim() === '')
) {
errors.value.accessToken = '请填写 Access Token'
hasError = true
}
// 分组类型验证
if (
form.value.accountType === 'group' &&
(!form.value.groupId || form.value.groupId.trim() === '')
) {
showToast('请选择一个分组', 'error')
hasError = true
}
if (hasError) {
return
}
loading.value = true
try {
const data = {
name: form.value.name,
description: form.value.description,
accountType: form.value.accountType,
groupId: form.value.accountType === 'group' ? form.value.groupId : undefined,
proxy: form.value.proxy.enabled
? {
type: form.value.proxy.type,
host: form.value.proxy.host,
port: parseInt(form.value.proxy.port),
username: form.value.proxy.username || null,
password: form.value.proxy.password || null
}
: null
}
if (form.value.platform === 'claude') {
// Claude手动模式需要构建claudeAiOauth对象
const expiresInMs = form.value.refreshToken
? 10 * 60 * 1000 // 10分钟
: 365 * 24 * 60 * 60 * 1000 // 1年
data.claudeAiOauth = {
accessToken: form.value.accessToken,
refreshToken: form.value.refreshToken || '',
expiresAt: Date.now() + expiresInMs,
scopes: ['user:inference']
}
data.priority = form.value.priority || 50
} 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
}
} else if (form.value.platform === 'claude-console') {
// 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
data.rateLimitDuration = form.value.rateLimitDuration || 60
} 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
data.rateLimitDuration = form.value.rateLimitDuration || 60
}
let result
if (form.value.platform === 'claude') {
result = await accountsStore.createClaudeAccount(data)
} else if (form.value.platform === 'claude-console') {
result = await accountsStore.createClaudeConsoleAccount(data)
} else if (form.value.platform === 'bedrock') {
result = await accountsStore.createBedrockAccount(data)
} else if (form.value.platform === 'openai') {
result = await accountsStore.createOpenAIAccount(data)
} else {
result = await accountsStore.createGeminiAccount(data)
}
emit('success', result)
} catch (error) {
showToast(error.message || '账户创建失败', 'error')
} finally {
loading.value = false
}
}
// 更新账户
const updateAccount = async () => {
// 清除之前的错误
errors.value.name = ''
// 验证账户名称
if (!form.value.name || form.value.name.trim() === '') {
errors.value.name = '请填写账户名称'
return
}
// 分组类型验证
if (
form.value.accountType === 'group' &&
(!form.value.groupId || form.value.groupId.trim() === '')
) {
showToast('请选择一个分组', 'error')
return
}
// 对于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 data = {
name: form.value.name,
description: form.value.description,
accountType: form.value.accountType,
groupId: form.value.accountType === 'group' ? form.value.groupId : undefined,
proxy: form.value.proxy.enabled
? {
type: form.value.proxy.type,
host: form.value.proxy.host,
port: parseInt(form.value.proxy.port),
username: form.value.proxy.username || null,
password: form.value.proxy.password || null
}
: null
}
// 只有非空时才更新token
if (form.value.accessToken || form.value.refreshToken) {
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: form.value.accessToken || '',
refreshToken: form.value.refreshToken || '',
expiresAt: Date.now() + expiresInMs,
scopes: ['user:inference']
}
} 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: 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 (props.account.platform === 'gemini' && form.value.projectId) {
data.projectId = form.value.projectId
}
// Claude 官方账号优先级更新
if (props.account.platform === 'claude') {
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
data.rateLimitDuration = form.value.rateLimitDuration || 60
}
// 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
data.rateLimitDuration = form.value.rateLimitDuration || 60
}
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 === 'bedrock') {
await accountsStore.updateBedrockAccount(props.account.id, data)
} else if (props.account.platform === 'openai') {
await accountsStore.updateOpenAIAccount(props.account.id, data)
} else {
await accountsStore.updateGeminiAccount(props.account.id, data)
}
emit('success')
} catch (error) {
showToast(error.message || '账户更新失败', 'error')
} 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 = ''
}
}
)
// 监听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 = ''
}
}
)
// 分组相关数据
const groups = ref([])
const loadingGroups = ref(false)
const showGroupManagement = ref(false)
// 根据平台筛选分组
const filteredGroups = computed(() => {
const platformFilter = form.value.platform === 'claude-console' ? 'claude' : form.value.platform
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 handleGroupRefresh = async () => {
await loadGroups()
}
// 监听平台变化,重置表单
watch(
() => form.value.platform,
(newPlatform) => {
// 处理添加方式的自动切换
if (newPlatform === 'claude-console' || newPlatform === 'bedrock') {
form.value.addType = 'manual' // Claude Console 和 Bedrock 只支持手动模式
} else if (newPlatform === 'claude') {
// 切换到 Claude 时,使用 Setup Token 作为默认方式
form.value.addType = 'setup-token'
} else if (newPlatform === 'gemini') {
// 切换到 Gemini 时,使用 OAuth 作为默认方式
form.value.addType = 'oauth'
}
// 平台变化时,清空分组选择
if (form.value.accountType === 'group') {
form.value.groupId = ''
}
}
)
// 监听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')
console.log('Successfully extracted authorization code from URL')
} else {
// URL 中没有 code 参数
showToast('URL 中未找到授权码参数,请检查链接是否正确', 'error')
}
} catch (error) {
// URL 解析失败
console.error('Failed to parse URL:', error)
showToast('链接格式错误,请检查是否为完整的 URL', 'error')
}
} else {
// 错误的 URL不是 localhost:45462 开头)
showToast('请粘贴以 http://localhost:45462 开头的链接', 'error')
}
}
// 如果不是 URL保持原值兼容直接输入授权码
})
// 监听账户类型变化
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 = {}
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 =
newAccount.proxy && newAccount.proxy.host && newAccount.proxy.port
? {
enabled: true,
type: newAccount.proxy.type || 'socks5',
host: newAccount.proxy.host,
port: newAccount.proxy.port,
username: newAccount.proxy.username || '',
password: newAccount.proxy.password || ''
}
: {
enabled: false,
type: 'socks5',
host: '',
port: '',
username: '',
password: ''
}
form.value = {
platform: newAccount.platform,
addType: 'oauth',
name: newAccount.name,
description: newAccount.description || '',
accountType: newAccount.accountType || 'shared',
groupId: '',
projectId: newAccount.projectId || '',
accessToken: '',
refreshToken: '',
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).join('\n')
}
// 处理数组格式(向后兼容)
if (Array.isArray(models)) {
return models.join('\n')
}
return ''
})(),
userAgent: newAccount.userAgent || '',
rateLimitDuration: newAccount.rateLimitDuration || 60,
// Bedrock 特定字段
accessKeyId: '', // 编辑模式不显示现有的访问密钥
secretAccessKey: '', // 编辑模式不显示现有的秘密密钥
region: newAccount.region || '',
sessionToken: '', // 编辑模式不显示现有的会话令牌
defaultModel: newAccount.defaultModel || '',
smallFastModel: newAccount.smallFastModel || ''
}
// 如果是分组类型加载分组ID
if (newAccount.accountType === 'group') {
// 先加载分组列表
loadGroups().then(() => {
// 如果账户有 groupInfo直接使用它的 groupId
if (newAccount.groupInfo && newAccount.groupInfo.id) {
form.value.groupId = newAccount.groupInfo.id
} else {
// 否则查找账户所属的分组
groups.value.forEach((group) => {
apiClient
.get(`/admin/account-groups/${group.id}/members`)
.then((response) => {
const members = response.data || []
if (members.some((m) => m.id === newAccount.id)) {
form.value.groupId = group.id
}
})
.catch(() => {})
})
}
})
}
}
},
{ immediate: true }
)
</script>