feat: 添加多模型支持和OpenAI兼容接口

- 新增 Gemini 模型支持和账户管理功能
- 实现 OpenAI 格式到 Claude/Gemini 的请求转换
- 添加自动 token 刷新服务,支持提前刷新策略
- 增强 Web 管理界面,支持 Gemini 账户管理
- 优化 token 显示,添加掩码功能
- 完善日志记录和错误处理

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
shaw
2025-07-22 10:17:39 +08:00
parent 4f0d8db757
commit 38c1fc4785
20 changed files with 4551 additions and 189 deletions

View File

@@ -158,7 +158,7 @@
<div class="stat-card">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-semibold text-gray-600 mb-1">Claude账户</p>
<p class="text-sm font-semibold text-gray-600 mb-1">服务账户</p>
<p class="text-3xl font-bold text-gray-900">{{ dashboardData.totalAccounts }}</p>
<p class="text-xs text-gray-500 mt-1">
活跃: {{ dashboardData.activeAccounts || 0 }}
@@ -406,10 +406,37 @@
</div>
</div>
<!-- API Keys Token消耗趋势图 -->
<!-- API Keys 使用趋势图 -->
<div class="mb-8">
<div class="card p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">API Keys Token 消耗趋势</h3>
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900">API Keys 使用趋势</h3>
<!-- 维度切换按钮 -->
<div class="flex gap-1 bg-gray-100 rounded-lg p-1">
<button
@click="apiKeysTrendMetric = 'requests'; updateApiKeysUsageTrendChart()"
:class="[
'px-3 py-1 rounded-md text-sm font-medium transition-colors',
apiKeysTrendMetric === 'requests'
? 'bg-white text-blue-600 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
]"
>
<i class="fas fa-exchange-alt mr-1"></i>请求次数
</button>
<button
@click="apiKeysTrendMetric = 'tokens'; updateApiKeysUsageTrendChart()"
:class="[
'px-3 py-1 rounded-md text-sm font-medium transition-colors',
apiKeysTrendMetric === 'tokens'
? 'bg-white text-blue-600 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
]"
>
<i class="fas fa-coins mr-1"></i>Token 数量
</button>
</div>
</div>
<div class="mb-4 text-sm text-gray-600">
<span v-if="apiKeysTrendData.totalApiKeys > 10">
共 {{ apiKeysTrendData.totalApiKeys }} 个 API Key显示使用量前 10 个
@@ -770,8 +797,8 @@
<div class="card p-6">
<div class="flex flex-col md:flex-row justify-between items-center gap-4 mb-6">
<div>
<h3 class="text-xl font-bold text-gray-900 mb-2">Claude 账户管理</h3>
<p class="text-gray-600">管理您的 Claude 账户代理配置</p>
<h3 class="text-xl font-bold text-gray-900 mb-2">账户管理</h3>
<p class="text-gray-600">管理您的 Claude 和 Gemini 账户代理配置</p>
</div>
<button
@click.stop="openCreateAccountModal"
@@ -799,6 +826,7 @@
<thead class="bg-gray-50/80 backdrop-blur-sm">
<tr>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">名称</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">平台</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">类型</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">状态</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">代理</th>
@@ -829,6 +857,16 @@
</div>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span v-if="account.platform === 'gemini'"
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-yellow-100 text-yellow-800">
<i class="fas fa-robot mr-1"></i>Gemini
</span>
<span v-else
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-indigo-100 text-indigo-800">
<i class="fas fa-brain mr-1"></i>Claude
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span v-if="account.scopes && account.scopes.length > 0"
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-blue-100 text-blue-800">
@@ -1801,39 +1839,65 @@
>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">时间窗口 (可选)</label>
<input
v-model="apiKeyForm.rateLimitWindow"
type="number"
min="1"
placeholder="留空表示无限制"
class="form-input w-full"
>
<p class="text-xs text-gray-500 mt-2">设置时间窗口分钟在此时间内限制请求次数或Token使用量</p>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">请求次数限制 (可选)</label>
<input
v-model="apiKeyForm.rateLimitRequests"
type="number"
min="1"
placeholder="留空表示无限制"
class="form-input w-full"
>
<p class="text-xs text-gray-500 mt-2">在时间窗口内允许的最大请求次数</p>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">时间窗口内 Token 限制 (可选)</label>
<input
v-model="apiKeyForm.tokenLimit"
type="number"
placeholder="留空表示无限制"
class="form-input w-full"
>
<p class="text-xs text-gray-500 mt-2">设置在时间窗口内的最大 Token 使用量(需同时设置时间窗口)</p>
<!-- 速率限制设置 -->
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 space-y-4">
<div class="flex items-start gap-3 mb-3">
<div class="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center flex-shrink-0">
<i class="fas fa-tachometer-alt text-white text-sm"></i>
</div>
<div class="flex-1">
<h4 class="font-semibold text-gray-800 mb-1">速率限制设置 (可选)</h4>
<p class="text-sm text-gray-600">控制 API Key 的使用频率和资源消耗</p>
</div>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">时间窗口 (分钟)</label>
<input
v-model="apiKeyForm.rateLimitWindow"
type="number"
min="1"
placeholder="留空表示无限制"
class="form-input w-full"
>
<p class="text-xs text-gray-500 mt-2">设置一个时间段(以分钟为单位),用于计算速率限制</p>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">时间窗口内的请求次数限制</label>
<input
v-model="apiKeyForm.rateLimitRequests"
type="number"
min="1"
placeholder="留空表示无限制"
class="form-input w-full"
>
<p class="text-xs text-gray-500 mt-2">在时间窗口内允许的最大请求次数(需要先设置时间窗口)</p>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">时间窗口内的 Token 使用量限制</label>
<input
v-model="apiKeyForm.tokenLimit"
type="number"
placeholder="留空表示无限制"
class="form-input w-full"
>
<p class="text-xs text-gray-500 mt-2">在时间窗口内允许消耗的最大 Token 数量(需要先设置时间窗口)</p>
</div>
<!-- 示例说明 -->
<div class="bg-blue-100 rounded-lg p-3 mt-3">
<h5 class="text-sm font-semibold text-blue-800 mb-2">💡 使用示例</h5>
<div class="text-xs text-blue-700 space-y-1">
<p><strong>示例1:</strong> 时间窗口=60请求次数限制=100</p>
<p class="ml-4">→ 每60分钟内最多允许100次请求</p>
<p class="mt-2"><strong>示例2:</strong> 时间窗口=10Token限制=50000</p>
<p class="ml-4">→ 每10分钟内最多消耗50,000个Token</p>
<p class="mt-2"><strong>示例3:</strong> 时间窗口=30请求次数限制=50Token限制=100000</p>
<p class="ml-4">→ 每30分钟内最多50次请求且总Token不超过100,000</p>
</div>
</div>
</div>
<div>
@@ -1858,21 +1922,78 @@
></textarea>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">服务权限</label>
<div class="flex gap-4">
<label class="flex items-center cursor-pointer">
<input
type="radio"
v-model="apiKeyForm.permissions"
value="all"
class="mr-2"
>
<span class="text-sm text-gray-700">全部服务</span>
</label>
<label class="flex items-center cursor-pointer">
<input
type="radio"
v-model="apiKeyForm.permissions"
value="claude"
class="mr-2"
>
<span class="text-sm text-gray-700">仅 Claude</span>
</label>
<label class="flex items-center cursor-pointer">
<input
type="radio"
v-model="apiKeyForm.permissions"
value="gemini"
class="mr-2"
>
<span class="text-sm text-gray-700">仅 Gemini</span>
</label>
</div>
<p class="text-xs text-gray-500 mt-2">控制此 API Key 可以访问哪些服务</p>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">专属账号绑定 (可选)</label>
<select
v-model="apiKeyForm.claudeAccountId"
class="form-input w-full"
>
<option value="">使用共享账号池</option>
<option
v-for="account in dedicatedAccounts"
:key="account.id"
:value="account.id"
>
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
</option>
</select>
<div class="grid grid-cols-1 gap-3">
<div>
<label class="block text-sm font-medium text-gray-600 mb-1">Claude 专属账号</label>
<select
v-model="apiKeyForm.claudeAccountId"
class="form-input w-full"
:disabled="apiKeyForm.permissions === 'gemini'"
>
<option value="">使用共享账号池</option>
<option
v-for="account in dedicatedAccounts.filter(a => a.platform === 'claude')"
:key="account.id"
:value="account.id"
>
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-600 mb-1">Gemini 专属账号</label>
<select
v-model="apiKeyForm.geminiAccountId"
class="form-input w-full"
:disabled="apiKeyForm.permissions === 'claude'"
>
<option value="">使用共享账号池</option>
<option
v-for="account in dedicatedAccounts.filter(a => a.platform === 'gemini')"
:key="account.id"
:value="account.id"
>
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
</option>
</select>
</div>
</div>
<p class="text-xs text-gray-500 mt-2">选择专属账号后此API Key将只使用该账号不选择则使用共享账号池</p>
</div>
@@ -1984,40 +2105,66 @@
<p class="text-xs text-gray-500 mt-2">名称不可修改</p>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">时间窗口</label>
<input
v-model="editApiKeyForm.rateLimitWindow"
type="number"
min="1"
placeholder="留空表示无限制"
class="form-input w-full"
>
<p class="text-xs text-gray-500 mt-2">设置时间窗口分钟在此时间内限制请求次数或Token使用量</p>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">请求次数限制</label>
<input
v-model="editApiKeyForm.rateLimitRequests"
type="number"
min="1"
placeholder="留空表示无限制"
class="form-input w-full"
>
<p class="text-xs text-gray-500 mt-2">在时间窗口内允许的最大请求次数</p>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">时间窗口内 Token 限制</label>
<input
v-model="editApiKeyForm.tokenLimit"
type="number"
min="0"
placeholder="0 表示无限制"
class="form-input w-full"
>
<p class="text-xs text-gray-500 mt-2">设置在时间窗口内的最大 Token 使用量需同时设置时间窗口0 或留空表示无限制</p>
<!-- 速率限制设置 -->
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 space-y-4">
<div class="flex items-start gap-3 mb-3">
<div class="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center flex-shrink-0">
<i class="fas fa-tachometer-alt text-white text-sm"></i>
</div>
<div class="flex-1">
<h4 class="font-semibold text-gray-800 mb-1">速率限制设置</h4>
<p class="text-sm text-gray-600">控制 API Key 的使用频率和资源消耗</p>
</div>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">时间窗口 (分钟)</label>
<input
v-model="editApiKeyForm.rateLimitWindow"
type="number"
min="1"
placeholder="留空表示无限制"
class="form-input w-full"
>
<p class="text-xs text-gray-500 mt-2">设置一个时间段(以分钟为单位),用于计算速率限制</p>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">时间窗口内的请求次数限制</label>
<input
v-model="editApiKeyForm.rateLimitRequests"
type="number"
min="1"
placeholder="留空表示无限制"
class="form-input w-full"
>
<p class="text-xs text-gray-500 mt-2">在时间窗口内允许的最大请求次数(需要先设置时间窗口)</p>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">时间窗口内的 Token 使用量限制</label>
<input
v-model="editApiKeyForm.tokenLimit"
type="number"
min="0"
placeholder="0 表示无限制"
class="form-input w-full"
>
<p class="text-xs text-gray-500 mt-2">在时间窗口内允许消耗的最大 Token 数量需要先设置时间窗口0 或留空表示无限制</p>
</div>
<!-- 示例说明 -->
<div class="bg-blue-100 rounded-lg p-3 mt-3">
<h5 class="text-sm font-semibold text-blue-800 mb-2">💡 使用示例</h5>
<div class="text-xs text-blue-700 space-y-1">
<p><strong>示例1:</strong> 时间窗口=60请求次数限制=100</p>
<p class="ml-4">→ 每60分钟内最多允许100次请求</p>
<p class="mt-2"><strong>示例2:</strong> 时间窗口=10Token限制=50000</p>
<p class="ml-4">→ 每10分钟内最多消耗50,000个Token</p>
<p class="mt-2"><strong>示例3:</strong> 时间窗口=30请求次数限制=50Token限制=100000</p>
<p class="ml-4">→ 每30分钟内最多50次请求且总Token不超过100,000</p>
</div>
</div>
</div>
<div>
@@ -2032,21 +2179,78 @@
<p class="text-xs text-gray-500 mt-2">设置此 API Key 可同时处理的最大请求数0 或留空表示无限制</p>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">服务权限</label>
<div class="flex gap-4">
<label class="flex items-center cursor-pointer">
<input
type="radio"
v-model="editApiKeyForm.permissions"
value="all"
class="mr-2"
>
<span class="text-sm text-gray-700">全部服务</span>
</label>
<label class="flex items-center cursor-pointer">
<input
type="radio"
v-model="editApiKeyForm.permissions"
value="claude"
class="mr-2"
>
<span class="text-sm text-gray-700">仅 Claude</span>
</label>
<label class="flex items-center cursor-pointer">
<input
type="radio"
v-model="editApiKeyForm.permissions"
value="gemini"
class="mr-2"
>
<span class="text-sm text-gray-700">仅 Gemini</span>
</label>
</div>
<p class="text-xs text-gray-500 mt-2">控制此 API Key 可以访问哪些服务</p>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">专属账号绑定</label>
<select
v-model="editApiKeyForm.claudeAccountId"
class="form-input w-full"
>
<option value="">使用共享账号池</option>
<option
v-for="account in dedicatedAccounts"
:key="account.id"
:value="account.id"
>
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
</option>
</select>
<div class="grid grid-cols-1 gap-3">
<div>
<label class="block text-sm font-medium text-gray-600 mb-1">Claude 专属账号</label>
<select
v-model="editApiKeyForm.claudeAccountId"
class="form-input w-full"
:disabled="editApiKeyForm.permissions === 'gemini'"
>
<option value="">使用共享账号池</option>
<option
v-for="account in dedicatedAccounts.filter(a => a.platform === 'claude')"
:key="account.id"
:value="account.id"
>
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-600 mb-1">Gemini 专属账号</label>
<select
v-model="editApiKeyForm.geminiAccountId"
class="form-input w-full"
:disabled="editApiKeyForm.permissions === 'claude'"
>
<option value="">使用共享账号池</option>
<option
v-for="account in dedicatedAccounts.filter(a => a.platform === 'gemini')"
:key="account.id"
:value="account.id"
>
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
</option>
</select>
</div>
</div>
<p class="text-xs text-gray-500 mt-2">修改绑定账号将影响此API Key的请求路由</p>
</div>
@@ -2222,7 +2426,7 @@
</div>
</div>
<!-- 创建 Claude 账户模态框 -->
<!-- 创建账户模态框 -->
<div v-if="showCreateAccountModal" class="fixed inset-0 modal z-50 flex items-center justify-center p-4">
<div class="modal-content w-full max-w-2xl p-8 mx-auto max-h-[90vh] overflow-y-auto custom-scrollbar">
<div class="flex items-center justify-between mb-6">
@@ -2230,7 +2434,7 @@
<div class="w-10 h-10 bg-gradient-to-br from-green-500 to-green-600 rounded-xl flex items-center justify-center">
<i class="fas fa-user-circle text-white"></i>
</div>
<h3 class="text-xl font-bold text-gray-900">添加 Claude 账户</h3>
<h3 class="text-xl font-bold text-gray-900">添加账户</h3>
</div>
<button
@click="closeCreateAccountModal"
@@ -2264,6 +2468,30 @@
<!-- 步骤1: 基本信息和代理设置 -->
<div v-if="oauthStep === 1">
<div class="space-y-6">
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">平台</label>
<div class="flex gap-4">
<label class="flex items-center cursor-pointer">
<input
type="radio"
v-model="accountForm.platform"
value="claude"
class="mr-2"
>
<span class="text-sm text-gray-700">Claude</span>
</label>
<label class="flex items-center cursor-pointer">
<input
type="radio"
v-model="accountForm.platform"
value="gemini"
class="mr-2"
>
<span class="text-sm text-gray-700">Gemini</span>
</label>
</div>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">添加方式</label>
<div class="flex gap-4">
@@ -2336,6 +2564,35 @@
</p>
</div>
<!-- Gemini 项目编号字段 -->
<div v-if="accountForm.platform === 'gemini'">
<label class="block text-sm font-semibold text-gray-700 mb-3">项目编号 (可选)</label>
<input
v-model="accountForm.projectId"
type="text"
class="form-input w-full"
placeholder="例如123456789012纯数字"
>
<div class="mt-2 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<div class="flex items-start gap-2">
<i class="fas fa-info-circle text-yellow-600 mt-0.5"></i>
<div class="text-xs text-yellow-700">
<p class="font-medium mb-1">Google Cloud/Workspace 账号需要提供项目编号</p>
<p>某些 Google 账号(特别是绑定了 Google Cloud 的账号)会被识别为 Workspace 账号,需要提供额外的项目编号。</p>
<div class="mt-2 p-2 bg-white rounded border border-yellow-300">
<p class="font-medium mb-1">如何获取项目编号:</p>
<ol class="list-decimal list-inside space-y-1 ml-2">
<li>访问 <a href="https://console.cloud.google.com/welcome" target="_blank" class="text-blue-600 hover:underline font-medium">Google Cloud Console</a></li>
<li>复制<span class="font-semibold text-red-600">项目编号Project Number</span>通常是12位纯数字</li>
<li class="text-red-600">⚠️ 注意不要复制项目IDProject ID要复制项目编号</li>
</ol>
</div>
<p class="mt-2"><strong>提示:</strong>如果您的账号是普通个人账号(未绑定 Google Cloud请留空此字段。</p>
</div>
</div>
</div>
</div>
<!-- 手动输入 Token 字段 -->
<div v-if="accountForm.addType === 'manual'" class="space-y-4 bg-blue-50 p-4 rounded-lg border border-blue-200">
<div class="flex items-start gap-3 mb-4">
@@ -2344,16 +2601,24 @@
</div>
<div>
<h5 class="font-semibold text-blue-900 mb-2">手动输入 Token</h5>
<p class="text-sm text-blue-800 mb-2">请输入有效的 Claude Access Token。如果您有 Refresh Token建议也一并填写以支持自动刷新。</p>
<p v-if="accountForm.platform === 'claude'" class="text-sm text-blue-800 mb-2">
请输入有效的 Claude Access Token。如果您有 Refresh Token建议也一并填写以支持自动刷新。
</p>
<p v-else-if="accountForm.platform === 'gemini'" class="text-sm text-blue-800 mb-2">
请输入有效的 Gemini Access Token。如果您有 Refresh Token建议也一并填写以支持自动刷新。
</p>
<div class="bg-white/80 rounded-lg p-3 mt-2 mb-2 border border-blue-300">
<p class="text-sm text-blue-900 font-medium mb-1">
<i class="fas fa-folder-open mr-1"></i>
获取 Access Token 的方法:
</p>
<p class="text-xs text-blue-800">
<p v-if="accountForm.platform === 'claude'" class="text-xs text-blue-800">
请从已登录 Claude Code 的机器上获取 <code class="bg-blue-100 px-1 py-0.5 rounded font-mono">~/.claude/.credentials.json</code> 文件中的凭证,
请勿使用 Claude 官网 API Keys 页面的密钥。
</p>
<p v-else-if="accountForm.platform === 'gemini'" class="text-xs text-blue-800">
请从已登录 Gemini CLI 的机器上获取 <code class="bg-blue-100 px-1 py-0.5 rounded font-mono">~/.config/gemini/credentials.json</code> 文件中的凭证。
</p>
</div>
<p class="text-xs text-blue-600">💡 如果未填写 Refresh TokenToken 过期后需要手动更新。</p>
</div>
@@ -2365,7 +2630,7 @@
v-model="accountForm.accessToken"
rows="4"
class="form-input w-full resize-none font-mono text-sm"
placeholder="sk-ant-oat01-..."
:placeholder="accountForm.platform === 'claude' ? 'sk-ant-oat01-...' : 'ya29.a0A...'"
required
></textarea>
</div>
@@ -2376,7 +2641,7 @@
v-model="accountForm.refreshToken"
rows="4"
class="form-input w-full resize-none font-mono text-sm"
placeholder="sk-ant-ort01-..."
:placeholder="accountForm.platform === 'claude' ? 'sk-ant-ort01-...' : '1//0g...'"
></textarea>
<p class="text-xs text-gray-500 mt-2">如果有 Refresh Token填写后系统可以自动刷新过期的 Access Token</p>
</div>
@@ -2384,7 +2649,10 @@
<div class="border-t pt-6">
<label class="block text-sm font-semibold text-gray-700 mb-3">代理设置 (可选)</label>
<p class="text-sm text-gray-500 mb-4">如果需要使用代理访问Claude服务请配置代理信息。OAuth授权也将通过此代理进行。</p>
<p class="text-sm text-gray-500 mb-4">
<span v-if="accountForm.platform === 'claude'">如果需要使用代理访问Claude服务请配置代理信息。OAuth授权也将通过此代理进行。</span>
<span v-else-if="accountForm.platform === 'gemini'">如果需要使用代理访问Gemini服务请配置代理信息。OAuth授权也将通过此代理进行。</span>
</p>
<select
v-model="accountForm.proxyType"
class="form-input w-full"
@@ -2472,7 +2740,8 @@
<!-- 步骤2: OAuth 授权 -->
<div v-if="oauthStep === 2">
<div class="space-y-6">
<!-- Claude OAuth 流程 -->
<div v-if="accountForm.platform === 'claude'" class="space-y-6">
<!-- 获取授权URL -->
<div v-if="!oauthData.authUrl" class="text-center py-8">
<div class="w-16 h-16 mx-auto mb-4 bg-blue-100 rounded-full flex items-center justify-center">
@@ -2555,6 +2824,90 @@
</div>
</div>
<!-- Gemini OAuth 流程 -->
<div v-else-if="accountForm.platform === 'gemini'" class="space-y-6">
<!-- 获取授权URL -->
<div v-if="!geminiOauthData.authUrl" class="text-center py-8">
<div class="w-16 h-16 mx-auto mb-4 bg-green-100 rounded-full flex items-center justify-center">
<i class="fas fa-link text-green-600 text-2xl"></i>
</div>
<h5 class="text-lg font-semibold text-gray-900 mb-2">获取授权链接</h5>
<p class="text-gray-600 mb-6">点击下方按钮生成Gemini OAuth授权链接</p>
<button
@click="generateAuthUrl()"
:disabled="authUrlLoading"
class="btn btn-primary px-8 py-3 font-semibold"
>
<div v-if="authUrlLoading" class="loading-spinner mr-2"></div>
<i v-else class="fas fa-magic mr-2"></i>
{{ authUrlLoading ? '生成中...' : '生成授权链接' }}
</button>
</div>
<!-- 显示授权URL和轮询状态 -->
<div v-if="geminiOauthData.authUrl">
<div class="bg-green-50 border border-green-200 rounded-xl p-6 mb-6">
<div class="flex items-start gap-3 mb-4">
<div class="w-8 h-8 bg-green-500 rounded-lg flex items-center justify-center flex-shrink-0 mt-1">
<i class="fas fa-info text-white text-sm"></i>
</div>
<div>
<h5 class="font-semibold text-green-900 mb-2">操作说明</h5>
<ol class="text-sm text-green-800 space-y-1 list-decimal list-inside">
<li>点击下方的授权链接在新页面中完成Google账号登录</li>
<li>查看并授权所请求的权限</li>
<li>授权完成后,页面会显示授权码</li>
<li>复制授权码并粘贴到下方输入框中</li>
</ol>
</div>
</div>
</div>
<div class="space-y-4">
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">授权链接</label>
<div class="flex gap-2">
<input
:value="geminiOauthData.authUrl"
readonly
class="form-input flex-1 font-mono text-sm bg-gray-50"
>
<button
@click="copyToClipboard(geminiOauthData.authUrl)"
class="btn btn-primary px-4 py-2 flex items-center gap-2"
>
<i class="fas fa-copy"></i>复制
</button>
<a
:href="geminiOauthData.authUrl"
target="_blank"
class="btn btn-success px-4 py-2 flex items-center gap-2"
>
<i class="fas fa-external-link-alt"></i>打开
</a>
</div>
</div>
<!-- 授权码输入框 -->
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">
<i class="fas fa-key text-green-500 mr-2"></i>授权码
</label>
<textarea
v-model="geminiOauthData.code"
rows="3"
class="form-input w-full resize-none font-mono text-sm"
placeholder="粘贴从授权页面复制的授权码..."
></textarea>
<p class="text-xs text-gray-500 mt-2">
<i class="fas fa-info-circle mr-1"></i>
授权完成后,从回调页面复制授权码并粘贴到此处
</p>
</div>
</div>
</div>
</div>
<div class="flex gap-3 pt-6">
<button
type="button"
@@ -2563,7 +2916,9 @@
>
<i class="fas fa-arrow-left mr-2"></i>上一步
</button>
<!-- Claude 完成按钮 -->
<button
v-if="accountForm.platform === 'claude'"
type="button"
@click="createOAuthAccount()"
:disabled="!oauthData.callbackUrl || !oauthData.authUrl || createAccountLoading"
@@ -2573,12 +2928,24 @@
<i v-else class="fas fa-check mr-2"></i>
{{ createAccountLoading ? '创建中...' : '完成创建' }}
</button>
<!-- Gemini 完成按钮 -->
<button
v-else-if="accountForm.platform === 'gemini'"
type="button"
@click="createGeminiOAuthAccount()"
:disabled="!geminiOauthData.code || !geminiOauthData.authUrl || createAccountLoading"
class="btn btn-success flex-1 py-3 px-6 font-semibold"
>
<div v-if="createAccountLoading" class="loading-spinner mr-2"></div>
<i v-else class="fas fa-check mr-2"></i>
{{ createAccountLoading ? '创建中...' : '使用授权码创建账户' }}
</button>
</div>
</div>
</div>
</div>
<!-- 编辑 Claude 账户模态框 -->
<!-- 编辑账户模态框 -->
<div v-if="showEditAccountModal" class="fixed inset-0 modal z-50 flex items-center justify-center p-4">
<div class="modal-content w-full max-w-2xl p-8 mx-auto max-h-[90vh] overflow-y-auto custom-scrollbar">
<div class="flex items-center justify-between mb-6">
@@ -2586,7 +2953,7 @@
<div class="w-10 h-10 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl flex items-center justify-center">
<i class="fas fa-edit text-white"></i>
</div>
<h3 class="text-xl font-bold text-gray-900">编辑 Claude 账户</h3>
<h3 class="text-xl font-bold text-gray-900">编辑账户</h3>
</div>
<button
@click="closeEditAccountModal"
@@ -2656,6 +3023,35 @@
</div>
<!-- Token 更新区域 -->
<!-- Gemini 项目编号字段(编辑模式) -->
<div v-if="editAccountForm.platform === 'gemini'">
<label class="block text-sm font-semibold text-gray-700 mb-3">项目编号 (可选)</label>
<input
v-model="editAccountForm.projectId"
type="text"
class="form-input w-full"
placeholder="例如123456789012纯数字"
>
<div class="mt-2 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<div class="flex items-start gap-2">
<i class="fas fa-info-circle text-yellow-600 mt-0.5"></i>
<div class="text-xs text-yellow-700">
<p class="font-medium mb-1">Google Cloud/Workspace 账号需要提供项目编号</p>
<p>如果您的账号被识别为 Workspace 账号,请提供项目编号。留空将尝试自动检测。</p>
<div class="mt-2 p-2 bg-white rounded border border-yellow-300">
<p class="font-medium mb-1">如何获取项目编号:</p>
<ol class="list-decimal list-inside space-y-1 ml-2">
<li>访问 <a href="https://console.cloud.google.com/welcome" target="_blank" class="text-blue-600 hover:underline font-medium">Google Cloud Console</a></li>
<li>复制<span class="font-semibold text-red-600">项目编号Project Number</span>通常是12位纯数字</li>
<li class="text-red-600">⚠️ 注意不要复制项目IDProject ID要复制项目编号</li>
</ol>
</div>
<p class="mt-2"><strong>提示:</strong>如果您的账号是普通个人账号(未绑定 Google Cloud请留空此字段。</p>
</div>
</div>
</div>
</div>
<div class="bg-amber-50 p-4 rounded-lg border border-amber-200">
<div class="flex items-start gap-3 mb-4">
<div class="w-8 h-8 bg-amber-500 rounded-lg flex items-center justify-center flex-shrink-0 mt-1">
@@ -2868,6 +3264,36 @@
</form>
</div>
</div>
<!-- 确认弹窗 -->
<div v-if="showConfirmModal" class="fixed inset-0 modal z-50 flex items-center justify-center p-4">
<div class="modal-content w-full max-w-md p-6 mx-auto">
<div class="flex items-start gap-4 mb-6">
<div class="w-12 h-12 bg-gradient-to-br from-yellow-400 to-yellow-500 rounded-full flex items-center justify-center flex-shrink-0">
<i class="fas fa-exclamation text-white text-xl"></i>
</div>
<div class="flex-1">
<h3 class="text-lg font-bold text-gray-900 mb-2">{{ confirmModal.title }}</h3>
<p class="text-gray-600 text-sm leading-relaxed whitespace-pre-line">{{ confirmModal.message }}</p>
</div>
</div>
<div class="flex gap-3">
<button
@click="handleConfirmCancel"
class="flex-1 px-4 py-2.5 bg-gray-100 text-gray-700 rounded-xl font-medium hover:bg-gray-200 transition-colors"
>
{{ confirmModal.cancelText || '取消' }}
</button>
<button
@click="handleConfirmOk"
class="flex-1 px-4 py-2.5 bg-gradient-to-r from-yellow-500 to-orange-500 text-white rounded-xl font-medium hover:from-yellow-600 hover:to-orange-600 transition-colors shadow-sm"
>
{{ confirmModal.confirmText || '继续' }}
</button>
</div>
</div>
</div>
<!-- Toast 通知组件 -->
<div v-for="(toast, index) in toasts" :key="toast.id"