mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 00:53:33 +00:00
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:
533
web/admin/app.js
533
web/admin/app.js
@@ -23,7 +23,7 @@ const app = createApp({
|
||||
tabs: [
|
||||
{ key: 'dashboard', name: '仪表板', icon: 'fas fa-tachometer-alt' },
|
||||
{ key: 'apiKeys', name: 'API Keys', icon: 'fas fa-key' },
|
||||
{ key: 'accounts', name: 'Claude账户', icon: 'fas fa-user-circle' },
|
||||
{ key: 'accounts', name: '账户管理', icon: 'fas fa-user-circle' },
|
||||
{ key: 'tutorial', name: '使用教程', icon: 'fas fa-graduation-cap' }
|
||||
],
|
||||
|
||||
@@ -86,6 +86,7 @@ const app = createApp({
|
||||
topApiKeys: [],
|
||||
totalApiKeys: 0
|
||||
},
|
||||
apiKeysTrendMetric: 'requests', // 'requests' 或 'tokens' - 默认显示请求次数
|
||||
|
||||
// 统一的日期筛选
|
||||
dateFilter: {
|
||||
@@ -120,6 +121,8 @@ const app = createApp({
|
||||
rateLimitWindow: '',
|
||||
rateLimitRequests: '',
|
||||
claudeAccountId: '',
|
||||
geminiAccountId: '',
|
||||
permissions: 'all', // 'claude', 'gemini', 'all'
|
||||
enableModelRestriction: false,
|
||||
restrictedModels: [],
|
||||
modelInput: ''
|
||||
@@ -163,6 +166,8 @@ const app = createApp({
|
||||
rateLimitWindow: '',
|
||||
rateLimitRequests: '',
|
||||
claudeAccountId: '',
|
||||
geminiAccountId: '',
|
||||
permissions: 'all',
|
||||
enableModelRestriction: false,
|
||||
restrictedModels: [],
|
||||
modelInput: ''
|
||||
@@ -174,6 +179,7 @@ const app = createApp({
|
||||
showCreateAccountModal: false,
|
||||
createAccountLoading: false,
|
||||
accountForm: {
|
||||
platform: 'claude', // 'claude' 或 'gemini'
|
||||
name: '',
|
||||
description: '',
|
||||
addType: 'oauth', // 'oauth' 或 'manual'
|
||||
@@ -184,7 +190,8 @@ const app = createApp({
|
||||
proxyHost: '',
|
||||
proxyPort: '',
|
||||
proxyUsername: '',
|
||||
proxyPassword: ''
|
||||
proxyPassword: '',
|
||||
projectId: '' // Gemini 项目编号
|
||||
},
|
||||
|
||||
// 编辑账户相关
|
||||
@@ -192,6 +199,7 @@ const app = createApp({
|
||||
editAccountLoading: false,
|
||||
editAccountForm: {
|
||||
id: '',
|
||||
platform: 'claude',
|
||||
name: '',
|
||||
description: '',
|
||||
accountType: 'shared',
|
||||
@@ -202,7 +210,8 @@ const app = createApp({
|
||||
proxyHost: '',
|
||||
proxyPort: '',
|
||||
proxyUsername: '',
|
||||
proxyPassword: ''
|
||||
proxyPassword: '',
|
||||
projectId: '' // Gemini 项目编号
|
||||
},
|
||||
|
||||
// OAuth 相关
|
||||
@@ -214,6 +223,15 @@ const app = createApp({
|
||||
callbackUrl: ''
|
||||
},
|
||||
|
||||
// Gemini OAuth 相关
|
||||
geminiOauthPolling: false,
|
||||
geminiOauthInterval: null,
|
||||
geminiOauthData: {
|
||||
sessionId: '',
|
||||
authUrl: '',
|
||||
code: ''
|
||||
},
|
||||
|
||||
// 用户菜单和账户修改相关
|
||||
userMenuOpen: false,
|
||||
currentUser: {
|
||||
@@ -228,6 +246,16 @@ const app = createApp({
|
||||
confirmPassword: ''
|
||||
},
|
||||
|
||||
// 确认弹窗相关
|
||||
showConfirmModal: false,
|
||||
confirmModal: {
|
||||
title: '',
|
||||
message: '',
|
||||
confirmText: '继续',
|
||||
cancelText: '取消',
|
||||
onConfirm: null,
|
||||
onCancel: null
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -312,6 +340,41 @@ const app = createApp({
|
||||
},
|
||||
|
||||
methods: {
|
||||
// 显示确认弹窗
|
||||
showConfirm(title, message, confirmText = '继续', cancelText = '取消') {
|
||||
return new Promise((resolve) => {
|
||||
this.confirmModal = {
|
||||
title,
|
||||
message,
|
||||
confirmText,
|
||||
cancelText,
|
||||
onConfirm: () => {
|
||||
this.showConfirmModal = false;
|
||||
resolve(true);
|
||||
},
|
||||
onCancel: () => {
|
||||
this.showConfirmModal = false;
|
||||
resolve(false);
|
||||
}
|
||||
};
|
||||
this.showConfirmModal = true;
|
||||
});
|
||||
},
|
||||
|
||||
// 处理确认弹窗确定按钮
|
||||
handleConfirmOk() {
|
||||
if (this.confirmModal.onConfirm) {
|
||||
this.confirmModal.onConfirm();
|
||||
}
|
||||
},
|
||||
|
||||
// 处理确认弹窗取消按钮
|
||||
handleConfirmCancel() {
|
||||
if (this.confirmModal.onCancel) {
|
||||
this.confirmModal.onCancel();
|
||||
}
|
||||
},
|
||||
|
||||
// 获取绑定账号名称
|
||||
getBoundAccountName(accountId) {
|
||||
const account = this.accounts.find(acc => acc.id === accountId);
|
||||
@@ -320,7 +383,9 @@ const app = createApp({
|
||||
|
||||
// 获取绑定到特定账号的API Key数量
|
||||
getBoundApiKeysCount(accountId) {
|
||||
return this.apiKeys.filter(key => key.claudeAccountId === accountId).length;
|
||||
return this.apiKeys.filter(key =>
|
||||
key.claudeAccountId === accountId || key.geminiAccountId === accountId
|
||||
).length;
|
||||
},
|
||||
|
||||
// 添加限制模型
|
||||
@@ -422,6 +487,7 @@ const app = createApp({
|
||||
openEditAccountModal(account) {
|
||||
this.editAccountForm = {
|
||||
id: account.id,
|
||||
platform: account.platform || 'claude',
|
||||
name: account.name,
|
||||
description: account.description || '',
|
||||
accountType: account.accountType || 'shared',
|
||||
@@ -432,7 +498,8 @@ const app = createApp({
|
||||
proxyHost: account.proxy ? account.proxy.host : '',
|
||||
proxyPort: account.proxy ? account.proxy.port : '',
|
||||
proxyUsername: account.proxy ? account.proxy.username : '',
|
||||
proxyPassword: account.proxy ? account.proxy.password : ''
|
||||
proxyPassword: account.proxy ? account.proxy.password : '',
|
||||
projectId: account.projectId || '' // 添加项目编号
|
||||
};
|
||||
this.showEditAccountModal = true;
|
||||
},
|
||||
@@ -442,6 +509,7 @@ const app = createApp({
|
||||
this.showEditAccountModal = false;
|
||||
this.editAccountForm = {
|
||||
id: '',
|
||||
platform: 'claude',
|
||||
name: '',
|
||||
description: '',
|
||||
accountType: 'shared',
|
||||
@@ -452,12 +520,29 @@ const app = createApp({
|
||||
proxyHost: '',
|
||||
proxyPort: '',
|
||||
proxyUsername: '',
|
||||
proxyPassword: ''
|
||||
proxyPassword: '',
|
||||
projectId: '' // 重置项目编号
|
||||
};
|
||||
},
|
||||
|
||||
// 更新账户
|
||||
async updateAccount() {
|
||||
// 对于Gemini账户,检查项目编号
|
||||
if (this.editAccountForm.platform === 'gemini') {
|
||||
if (!this.editAccountForm.projectId || this.editAccountForm.projectId.trim() === '') {
|
||||
// 使用自定义确认弹窗
|
||||
const confirmed = await this.showConfirm(
|
||||
'项目编号未填写',
|
||||
'您尚未填写项目编号。\n\n如果您的Google账号绑定了Google Cloud或被识别为Workspace账号,需要提供项目编号。\n如果您使用的是普通个人账号,可以继续不填写。',
|
||||
'继续保存',
|
||||
'返回填写'
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.editAccountLoading = true;
|
||||
try {
|
||||
// 验证账户类型切换
|
||||
@@ -478,19 +563,42 @@ const app = createApp({
|
||||
let updateData = {
|
||||
name: this.editAccountForm.name,
|
||||
description: this.editAccountForm.description,
|
||||
accountType: this.editAccountForm.accountType
|
||||
accountType: this.editAccountForm.accountType,
|
||||
projectId: this.editAccountForm.projectId || '' // 添加项目编号
|
||||
};
|
||||
|
||||
// 只在有值时才更新 token
|
||||
if (this.editAccountForm.accessToken.trim()) {
|
||||
// 构建新的 OAuth 数据
|
||||
const newOauthData = {
|
||||
accessToken: this.editAccountForm.accessToken,
|
||||
refreshToken: this.editAccountForm.refreshToken || '',
|
||||
expiresAt: Date.now() + (365 * 24 * 60 * 60 * 1000), // 默认设置1年后过期
|
||||
scopes: ['user:inference']
|
||||
};
|
||||
updateData.claudeAiOauth = newOauthData;
|
||||
if (this.editAccountForm.platform === 'gemini') {
|
||||
// Gemini OAuth 数据格式
|
||||
// 如果有 Refresh Token,设置10分钟过期;否则设置1年
|
||||
const expiresInMs = this.editAccountForm.refreshToken
|
||||
? (10 * 60 * 1000) // 10分钟
|
||||
: (365 * 24 * 60 * 60 * 1000); // 1年
|
||||
|
||||
const newOauthData = {
|
||||
access_token: this.editAccountForm.accessToken,
|
||||
refresh_token: this.editAccountForm.refreshToken || '',
|
||||
scope: 'https://www.googleapis.com/auth/cloud-platform',
|
||||
token_type: 'Bearer',
|
||||
expiry_date: Date.now() + expiresInMs
|
||||
};
|
||||
updateData.geminiOauth = newOauthData;
|
||||
} else {
|
||||
// Claude OAuth 数据格式
|
||||
// 如果有 Refresh Token,设置10分钟过期;否则设置1年
|
||||
const expiresInMs = this.editAccountForm.refreshToken
|
||||
? (10 * 60 * 1000) // 10分钟
|
||||
: (365 * 24 * 60 * 60 * 1000); // 1年
|
||||
|
||||
const newOauthData = {
|
||||
accessToken: this.editAccountForm.accessToken,
|
||||
refreshToken: this.editAccountForm.refreshToken || '',
|
||||
expiresAt: Date.now() + expiresInMs,
|
||||
scopes: ['user:inference']
|
||||
};
|
||||
updateData.claudeAiOauth = newOauthData;
|
||||
}
|
||||
}
|
||||
|
||||
// 更新代理配置
|
||||
@@ -506,7 +614,12 @@ const app = createApp({
|
||||
updateData.proxy = null;
|
||||
}
|
||||
|
||||
const response = await fetch(`/admin/claude-accounts/${this.editAccountForm.id}`, {
|
||||
// 根据平台选择端点
|
||||
const endpoint = this.editAccountForm.platform === 'gemini'
|
||||
? `/admin/gemini-accounts/${this.editAccountForm.id}`
|
||||
: `/admin/claude-accounts/${this.editAccountForm.id}`;
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -549,6 +662,7 @@ const app = createApp({
|
||||
// 重置账户表单
|
||||
resetAccountForm() {
|
||||
this.accountForm = {
|
||||
platform: 'claude',
|
||||
name: '',
|
||||
description: '',
|
||||
addType: 'oauth',
|
||||
@@ -559,7 +673,8 @@ const app = createApp({
|
||||
proxyHost: '',
|
||||
proxyPort: '',
|
||||
proxyUsername: '',
|
||||
proxyPassword: ''
|
||||
proxyPassword: '',
|
||||
projectId: '' // 重置项目编号
|
||||
};
|
||||
this.oauthStep = 1;
|
||||
this.oauthData = {
|
||||
@@ -567,10 +682,37 @@ const app = createApp({
|
||||
authUrl: '',
|
||||
callbackUrl: ''
|
||||
};
|
||||
this.geminiOauthData = {
|
||||
sessionId: '',
|
||||
authUrl: '',
|
||||
code: ''
|
||||
};
|
||||
// 停止 Gemini OAuth 轮询
|
||||
if (this.geminiOauthInterval) {
|
||||
clearInterval(this.geminiOauthInterval);
|
||||
this.geminiOauthInterval = null;
|
||||
}
|
||||
this.geminiOauthPolling = false;
|
||||
},
|
||||
|
||||
// OAuth步骤前进
|
||||
nextOAuthStep() {
|
||||
async nextOAuthStep() {
|
||||
// 对于Gemini账户,检查项目编号
|
||||
if (this.accountForm.platform === 'gemini' && this.oauthStep === 1 && this.accountForm.addType === 'oauth') {
|
||||
if (!this.accountForm.projectId || this.accountForm.projectId.trim() === '') {
|
||||
// 使用自定义确认弹窗
|
||||
const confirmed = await this.showConfirm(
|
||||
'项目编号未填写',
|
||||
'您尚未填写项目编号。\n\n如果您的Google账号绑定了Google Cloud或被识别为Workspace账号,需要提供项目编号。\n如果您使用的是普通个人账号,可以继续不填写。',
|
||||
'继续',
|
||||
'返回填写'
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.oauthStep < 3) {
|
||||
this.oauthStep++;
|
||||
}
|
||||
@@ -592,7 +734,11 @@ const app = createApp({
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch('/admin/claude-accounts/generate-auth-url', {
|
||||
const endpoint = this.accountForm.platform === 'gemini'
|
||||
? '/admin/gemini-accounts/generate-auth-url'
|
||||
: '/admin/claude-accounts/generate-auth-url';
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -606,8 +752,14 @@ const app = createApp({
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this.oauthData.authUrl = data.data.authUrl;
|
||||
this.oauthData.sessionId = data.data.sessionId;
|
||||
if (this.accountForm.platform === 'gemini') {
|
||||
this.geminiOauthData.authUrl = data.data.authUrl;
|
||||
this.geminiOauthData.sessionId = data.data.sessionId;
|
||||
// 不再自动开始轮询,改为手动输入授权码
|
||||
} else {
|
||||
this.oauthData.authUrl = data.data.authUrl;
|
||||
this.oauthData.sessionId = data.data.sessionId;
|
||||
}
|
||||
this.showToast('授权链接生成成功!', 'success', '生成成功');
|
||||
} else {
|
||||
this.showToast(data.message || '生成失败', 'error', '生成失败');
|
||||
@@ -633,6 +785,12 @@ const app = createApp({
|
||||
|
||||
// 创建OAuth账户
|
||||
async createOAuthAccount() {
|
||||
// 如果是 Gemini,不应该调用这个方法
|
||||
if (this.accountForm.platform === 'gemini') {
|
||||
console.error('createOAuthAccount should not be called for Gemini');
|
||||
return;
|
||||
}
|
||||
|
||||
this.createAccountLoading = true;
|
||||
try {
|
||||
// 首先交换authorization code获取token
|
||||
@@ -735,28 +893,65 @@ const app = createApp({
|
||||
};
|
||||
}
|
||||
|
||||
// 构建手动 OAuth 数据
|
||||
const manualOauthData = {
|
||||
accessToken: this.accountForm.accessToken,
|
||||
refreshToken: this.accountForm.refreshToken || '',
|
||||
expiresAt: Date.now() + (365 * 24 * 60 * 60 * 1000), // 默认设置1年后过期
|
||||
scopes: ['user:inference'] // 默认权限
|
||||
};
|
||||
// 根据平台构建 OAuth 数据
|
||||
let endpoint, bodyData;
|
||||
|
||||
// 创建账户
|
||||
const createResponse = await fetch('/admin/claude-accounts', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ' + this.authToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
if (this.accountForm.platform === 'gemini') {
|
||||
// Gemini 账户
|
||||
// 如果有 Refresh Token,设置10分钟过期;否则设置1年
|
||||
const expiresInMs = this.accountForm.refreshToken
|
||||
? (10 * 60 * 1000) // 10分钟
|
||||
: (365 * 24 * 60 * 60 * 1000); // 1年
|
||||
|
||||
const geminiOauthData = {
|
||||
access_token: this.accountForm.accessToken,
|
||||
refresh_token: this.accountForm.refreshToken || '',
|
||||
scope: 'https://www.googleapis.com/auth/cloud-platform',
|
||||
token_type: 'Bearer',
|
||||
expiry_date: Date.now() + expiresInMs
|
||||
};
|
||||
|
||||
endpoint = '/admin/gemini-accounts';
|
||||
bodyData = {
|
||||
name: this.accountForm.name,
|
||||
description: this.accountForm.description,
|
||||
geminiOauth: geminiOauthData,
|
||||
proxy: proxy,
|
||||
accountType: this.accountForm.accountType,
|
||||
projectId: this.accountForm.projectId || '' // 添加项目编号
|
||||
};
|
||||
} else {
|
||||
// Claude 账户
|
||||
// 如果有 Refresh Token,设置10分钟过期;否则设置1年
|
||||
const expiresInMs = this.accountForm.refreshToken
|
||||
? (10 * 60 * 1000) // 10分钟
|
||||
: (365 * 24 * 60 * 60 * 1000); // 1年
|
||||
|
||||
const manualOauthData = {
|
||||
accessToken: this.accountForm.accessToken,
|
||||
refreshToken: this.accountForm.refreshToken || '',
|
||||
expiresAt: Date.now() + expiresInMs,
|
||||
scopes: ['user:inference'] // 默认权限
|
||||
};
|
||||
|
||||
endpoint = '/admin/claude-accounts';
|
||||
bodyData = {
|
||||
name: this.accountForm.name,
|
||||
description: this.accountForm.description,
|
||||
claudeAiOauth: manualOauthData,
|
||||
proxy: proxy,
|
||||
accountType: this.accountForm.accountType
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
// 创建账户
|
||||
const createResponse = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ' + this.authToken
|
||||
},
|
||||
body: JSON.stringify(bodyData)
|
||||
});
|
||||
|
||||
const createData = await createResponse.json();
|
||||
@@ -790,6 +985,131 @@ const app = createApp({
|
||||
}
|
||||
},
|
||||
|
||||
// Gemini OAuth 轮询
|
||||
async startGeminiOAuthPolling() {
|
||||
if (this.geminiOauthPolling) return;
|
||||
|
||||
this.geminiOauthPolling = true;
|
||||
let attempts = 0;
|
||||
const maxAttempts = 30; // 最多轮询 30 次(60秒)
|
||||
|
||||
this.geminiOauthInterval = setInterval(async () => {
|
||||
attempts++;
|
||||
|
||||
try {
|
||||
const response = await fetch('/admin/gemini-accounts/poll-auth-status', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ' + this.authToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
sessionId: this.geminiOauthData.sessionId
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
// 授权成功
|
||||
this.stopGeminiOAuthPolling();
|
||||
this.geminiOauthData.code = 'authorized';
|
||||
|
||||
// 自动创建账户
|
||||
await this.createGeminiOAuthAccount(data.data.tokens);
|
||||
} else if (data.error === 'Authorization timeout' || attempts >= maxAttempts) {
|
||||
// 超时
|
||||
this.stopGeminiOAuthPolling();
|
||||
this.showToast('授权超时,请重试', 'error', '授权超时');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Polling error:', error);
|
||||
if (attempts >= maxAttempts) {
|
||||
this.stopGeminiOAuthPolling();
|
||||
this.showToast('轮询失败,请检查网络连接', 'error', '网络错误');
|
||||
}
|
||||
}
|
||||
}, 2000); // 每2秒轮询一次
|
||||
},
|
||||
|
||||
stopGeminiOAuthPolling() {
|
||||
if (this.geminiOauthInterval) {
|
||||
clearInterval(this.geminiOauthInterval);
|
||||
this.geminiOauthInterval = null;
|
||||
}
|
||||
this.geminiOauthPolling = false;
|
||||
},
|
||||
|
||||
// 创建 Gemini OAuth 账户
|
||||
async createGeminiOAuthAccount() {
|
||||
this.createAccountLoading = true;
|
||||
try {
|
||||
// 首先交换授权码获取 tokens
|
||||
const tokenResponse = await fetch('/admin/gemini-accounts/exchange-code', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ' + this.authToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
code: this.geminiOauthData.code,
|
||||
sessionId: this.geminiOauthData.sessionId
|
||||
})
|
||||
});
|
||||
|
||||
const tokenData = await tokenResponse.json();
|
||||
|
||||
if (!tokenData.success) {
|
||||
this.showToast(tokenData.message || '授权码交换失败', 'error', '交换失败');
|
||||
return;
|
||||
}
|
||||
|
||||
// 构建代理配置
|
||||
let proxy = null;
|
||||
if (this.accountForm.proxyType) {
|
||||
proxy = {
|
||||
type: this.accountForm.proxyType,
|
||||
host: this.accountForm.proxyHost,
|
||||
port: parseInt(this.accountForm.proxyPort),
|
||||
username: this.accountForm.proxyUsername || null,
|
||||
password: this.accountForm.proxyPassword || null
|
||||
};
|
||||
}
|
||||
|
||||
// 创建账户
|
||||
const response = await fetch('/admin/gemini-accounts', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ' + this.authToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: this.accountForm.name,
|
||||
description: this.accountForm.description,
|
||||
geminiOauth: tokenData.data.tokens,
|
||||
proxy: proxy,
|
||||
accountType: this.accountForm.accountType,
|
||||
projectId: this.accountForm.projectId || '' // 添加项目编号
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this.showToast('Gemini OAuth账户创建成功!', 'success', '账户创建成功');
|
||||
this.closeCreateAccountModal();
|
||||
await this.loadAccounts();
|
||||
} else {
|
||||
this.showToast(data.message || 'Account creation failed', 'error', 'Creation Failed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating Gemini OAuth account:', error);
|
||||
this.showToast('创建失败,请检查网络连接', 'error', '网络错误', 8000);
|
||||
} finally {
|
||||
this.createAccountLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
// 根据当前标签页加载数据
|
||||
loadCurrentTabData() {
|
||||
@@ -1160,18 +1480,50 @@ const app = createApp({
|
||||
async loadAccounts() {
|
||||
this.accountsLoading = true;
|
||||
try {
|
||||
const response = await fetch('/admin/claude-accounts', {
|
||||
headers: { 'Authorization': 'Bearer ' + this.authToken }
|
||||
});
|
||||
const data = await response.json();
|
||||
// 并行加载 Claude 和 Gemini 账户
|
||||
const [claudeResponse, geminiResponse] = await Promise.all([
|
||||
fetch('/admin/claude-accounts', {
|
||||
headers: { 'Authorization': 'Bearer ' + this.authToken }
|
||||
}),
|
||||
fetch('/admin/gemini-accounts', {
|
||||
headers: { 'Authorization': 'Bearer ' + this.authToken }
|
||||
})
|
||||
]);
|
||||
|
||||
if (data.success) {
|
||||
this.accounts = data.data || [];
|
||||
// 为每个账号计算绑定的API Key数量
|
||||
this.accounts.forEach(account => {
|
||||
account.boundApiKeysCount = this.apiKeys.filter(key => key.claudeAccountId === account.id).length;
|
||||
});
|
||||
const [claudeData, geminiData] = await Promise.all([
|
||||
claudeResponse.json(),
|
||||
geminiResponse.json()
|
||||
]);
|
||||
|
||||
// 合并账户数据
|
||||
const allAccounts = [];
|
||||
|
||||
if (claudeData.success) {
|
||||
const claudeAccounts = (claudeData.data || []).map(acc => ({
|
||||
...acc,
|
||||
platform: 'claude'
|
||||
}));
|
||||
allAccounts.push(...claudeAccounts);
|
||||
}
|
||||
|
||||
if (geminiData.success) {
|
||||
const geminiAccounts = (geminiData.data || []).map(acc => ({
|
||||
...acc,
|
||||
platform: 'gemini'
|
||||
}));
|
||||
allAccounts.push(...geminiAccounts);
|
||||
}
|
||||
|
||||
this.accounts = allAccounts;
|
||||
|
||||
// 为每个账号计算绑定的API Key数量
|
||||
this.accounts.forEach(account => {
|
||||
if (account.platform === 'claude') {
|
||||
account.boundApiKeysCount = this.apiKeys.filter(key => key.claudeAccountId === account.id).length;
|
||||
} else {
|
||||
account.boundApiKeysCount = this.apiKeys.filter(key => key.geminiAccountId === account.id).length;
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load accounts:', error);
|
||||
} finally {
|
||||
@@ -1253,7 +1605,13 @@ const app = createApp({
|
||||
},
|
||||
|
||||
async deleteApiKey(keyId) {
|
||||
if (!confirm('确定要删除这个 API Key 吗?')) return;
|
||||
const confirmed = await this.showConfirm(
|
||||
'删除 API Key',
|
||||
'确定要删除这个 API Key 吗?\n\n此操作不可撤销,删除后将无法恢复。',
|
||||
'确认删除',
|
||||
'取消'
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/admin/api-keys/' + keyId, {
|
||||
@@ -1350,6 +1708,13 @@ const app = createApp({
|
||||
await this.loadApiKeys();
|
||||
}
|
||||
|
||||
// 查找账户以确定平台类型
|
||||
const account = this.accounts.find(acc => acc.id === accountId);
|
||||
if (!account) {
|
||||
this.showToast('账户不存在', 'error', '删除失败');
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否有API Key绑定到此账号
|
||||
const boundKeysCount = this.getBoundApiKeysCount(accountId);
|
||||
if (boundKeysCount > 0) {
|
||||
@@ -1357,10 +1722,22 @@ const app = createApp({
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm('确定要删除这个 Claude 账户吗?')) return;
|
||||
const platformName = account.platform === 'gemini' ? 'Gemini' : 'Claude';
|
||||
const confirmed = await this.showConfirm(
|
||||
`删除 ${platformName} 账户`,
|
||||
`确定要删除这个 ${platformName} 账户吗?\n\n账户名称:${account.name}\n此操作不可撤销,删除后将无法恢复。`,
|
||||
'确认删除',
|
||||
'取消'
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
// 根据平台选择端点
|
||||
const endpoint = account.platform === 'gemini'
|
||||
? `/admin/gemini-accounts/${accountId}`
|
||||
: `/admin/claude-accounts/${accountId}`;
|
||||
|
||||
try {
|
||||
const response = await fetch('/admin/claude-accounts/' + accountId, {
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': 'Bearer ' + this.authToken }
|
||||
});
|
||||
@@ -1379,6 +1756,40 @@ const app = createApp({
|
||||
}
|
||||
},
|
||||
|
||||
// 刷新账户 Token
|
||||
async refreshAccountToken(accountId) {
|
||||
const account = this.accounts.find(acc => acc.id === accountId);
|
||||
if (!account) {
|
||||
this.showToast('账户不存在', 'error', '刷新失败');
|
||||
return;
|
||||
}
|
||||
|
||||
// 根据平台选择端点
|
||||
const endpoint = account.platform === 'gemini'
|
||||
? `/admin/gemini-accounts/${accountId}/refresh`
|
||||
: `/admin/claude-accounts/${accountId}/refresh`;
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + this.authToken }
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
const platformName = account.platform === 'gemini' ? 'Gemini' : 'Claude';
|
||||
this.showToast(`${platformName} Token 刷新成功`, 'success', '刷新成功');
|
||||
await this.loadAccounts();
|
||||
} else {
|
||||
this.showToast(data.message || '刷新失败', 'error', '刷新失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error refreshing token:', error);
|
||||
this.showToast('刷新失败,请检查网络连接', 'error', '网络错误');
|
||||
}
|
||||
},
|
||||
|
||||
// API Key 展示相关方法
|
||||
toggleApiKeyVisibility() {
|
||||
this.newApiKey.showFullKey = !this.newApiKey.showFullKey;
|
||||
@@ -1416,9 +1827,15 @@ const app = createApp({
|
||||
}
|
||||
},
|
||||
|
||||
closeNewApiKeyModal() {
|
||||
async closeNewApiKeyModal() {
|
||||
// 显示确认提示
|
||||
if (confirm('关闭后将无法再次查看完整的API Key,请确保已经妥善保存。确定要关闭吗?')) {
|
||||
const confirmed = await this.showConfirm(
|
||||
'关闭 API Key',
|
||||
'关闭后将无法再次查看完整的API Key,请确保已经妥善保存。\n\n确定要关闭吗?',
|
||||
'我已保存',
|
||||
'取消'
|
||||
);
|
||||
if (confirmed) {
|
||||
this.showNewApiKeyModal = false;
|
||||
this.newApiKey = { key: '', name: '', description: '', showFullKey: false };
|
||||
}
|
||||
@@ -1991,7 +2408,10 @@ const app = createApp({
|
||||
// 只显示前10个使用量最多的API Key
|
||||
this.apiKeysTrendData.topApiKeys.forEach((apiKeyId, index) => {
|
||||
const data = this.apiKeysTrendData.data.map(item => {
|
||||
return item.apiKeys[apiKeyId] ? item.apiKeys[apiKeyId].tokens : 0;
|
||||
if (!item.apiKeys[apiKeyId]) return 0;
|
||||
return this.apiKeysTrendMetric === 'tokens'
|
||||
? item.apiKeys[apiKeyId].tokens
|
||||
: item.apiKeys[apiKeyId].requests || 0;
|
||||
});
|
||||
|
||||
// 获取API Key名称
|
||||
@@ -2049,7 +2469,7 @@ const app = createApp({
|
||||
position: 'left',
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Token 数量'
|
||||
text: this.apiKeysTrendMetric === 'tokens' ? 'Token 数量' : '请求次数'
|
||||
},
|
||||
ticks: {
|
||||
callback: function(value) {
|
||||
@@ -2087,10 +2507,11 @@ const app = createApp({
|
||||
}
|
||||
return tooltipItems[0].label;
|
||||
},
|
||||
label: function(context) {
|
||||
label: (context) => {
|
||||
const label = context.dataset.label || '';
|
||||
const value = context.parsed.y;
|
||||
return label + ': ' + value.toLocaleString() + ' tokens';
|
||||
const unit = this.apiKeysTrendMetric === 'tokens' ? ' tokens' : ' 次';
|
||||
return label + ': ' + value.toLocaleString() + unit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> 时间窗口=10,Token限制=50000</p>
|
||||
<p class="ml-4">→ 每10分钟内最多消耗50,000个Token</p>
|
||||
<p class="mt-2"><strong>示例3:</strong> 时间窗口=30,请求次数限制=50,Token限制=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> 时间窗口=10,Token限制=50000</p>
|
||||
<p class="ml-4">→ 每10分钟内最多消耗50,000个Token</p>
|
||||
<p class="mt-2"><strong>示例3:</strong> 时间窗口=30,请求次数限制=50,Token限制=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">⚠️ 注意:不要复制项目ID(Project 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 Token,Token 过期后需要手动更新。</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">⚠️ 注意:不要复制项目ID(Project 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"
|
||||
|
||||
Reference in New Issue
Block a user