mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
feat: 实现账户分组管理功能和优化响应式设计
主要更新: - 实现账户分组管理功能,支持创建、编辑、删除分组 - 支持将账户添加到分组进行统一调度 - 优化 API Keys 页面响应式设计,解决操作栏被隐藏的问题 - 优化账户管理页面布局,合并平台/类型列,改进操作按钮布局 - 修复代理信息显示溢出问题 - 改进表格列宽分配,充分利用屏幕空间 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
108
README.md
108
README.md
@@ -133,6 +133,71 @@
|
||||
|
||||
---
|
||||
|
||||
## 🚀 脚本部署(推荐)
|
||||
|
||||
推荐使用管理脚本进行一键部署,简单快捷,自动处理所有依赖和配置。
|
||||
|
||||
### 快速安装
|
||||
|
||||
```bash
|
||||
# 下载并运行管理脚本
|
||||
curl -fsSL https://raw.githubusercontent.com/Wei-Shaw/claude-relay-service/main/scripts/manage.sh -o manage.sh
|
||||
chmod +x manage.sh
|
||||
./manage.sh install
|
||||
|
||||
# 安装后可以使用 crs 命令管理服务
|
||||
crs # 显示交互式菜单
|
||||
```
|
||||
|
||||
### 脚本功能
|
||||
|
||||
- ✅ **一键安装**: 自动检测系统环境,安装 Node.js 18+、Redis 等依赖
|
||||
- ✅ **交互式配置**: 友好的配置向导,设置端口、Redis 连接等
|
||||
- ✅ **自动启动**: 安装完成后自动启动服务并显示访问地址
|
||||
- ✅ **便捷管理**: 通过 `crs` 命令随时管理服务状态
|
||||
|
||||
### 管理命令
|
||||
|
||||
```bash
|
||||
crs install # 安装服务
|
||||
crs start # 启动服务
|
||||
crs stop # 停止服务
|
||||
crs restart # 重启服务
|
||||
crs status # 查看状态
|
||||
crs update # 更新服务
|
||||
crs uninstall # 卸载服务
|
||||
```
|
||||
|
||||
### 安装示例
|
||||
|
||||
```bash
|
||||
$ crs install
|
||||
|
||||
# 会依次询问:
|
||||
安装目录 (默认: ~/claude-relay-service):
|
||||
服务端口 (默认: 3000): 8080
|
||||
Redis 地址 (默认: localhost):
|
||||
Redis 端口 (默认: 6379):
|
||||
Redis 密码 (默认: 无密码):
|
||||
|
||||
# 安装完成后自动启动并显示:
|
||||
服务已成功安装并启动!
|
||||
|
||||
访问地址:
|
||||
本地 Web: http://localhost:8080/web
|
||||
公网 Web: http://YOUR_IP:8080/web
|
||||
|
||||
管理员账号信息已保存到: data/init.json
|
||||
```
|
||||
|
||||
### 系统要求
|
||||
|
||||
- 支持系统: Ubuntu/Debian、CentOS/RedHat、Arch Linux、macOS
|
||||
- 自动安装 Node.js 18+ 和 Redis
|
||||
- Redis 使用系统默认位置,数据独立于应用
|
||||
|
||||
---
|
||||
|
||||
## 📦 手动部署
|
||||
|
||||
### 第一步:环境准备
|
||||
@@ -214,7 +279,7 @@ npm run setup # 会随机生成后台账号密码信息,存储在 data/init.js
|
||||
# export ADMIN_PASSWORD=your-secure-password
|
||||
|
||||
# 启动服务
|
||||
npm run service:start:daemon # 后台运行(推荐)
|
||||
npm run service:start:daemon # 后台运行
|
||||
|
||||
# 查看状态
|
||||
npm run service:status
|
||||
@@ -222,11 +287,11 @@ npm run service:status
|
||||
|
||||
---
|
||||
|
||||
## 🐳 Docker 部署(推荐)
|
||||
## 🐳 Docker 部署
|
||||
|
||||
### 使用 Docker Hub 镜像(最简单)
|
||||
|
||||
> 🚀 推荐使用官方镜像,自动构建,始终保持最新版本
|
||||
> 🚀 使用官方镜像,自动构建,始终保持最新版本
|
||||
|
||||
```bash
|
||||
# 拉取镜像(支持 amd64 和 arm64)
|
||||
@@ -245,7 +310,7 @@ docker run -d \
|
||||
-e ADMIN_PASSWORD=my_secure_password \
|
||||
weishaw/claude-relay-service:latest
|
||||
|
||||
# 或使用 docker-compose(推荐)
|
||||
# 或使用 docker-compose
|
||||
# 创建 .env 文件用于 docker-compose 的环境变量:
|
||||
cat > .env << 'EOF'
|
||||
# 必填:安全密钥(请修改为随机值)
|
||||
@@ -294,35 +359,6 @@ EOF
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### 从源码构建
|
||||
|
||||
```bash
|
||||
# 1. 克隆项目
|
||||
git clone https://github.com/Wei-Shaw//claude-relay-service.git
|
||||
cd claude-relay-service
|
||||
|
||||
# 2. 创建环境变量文件
|
||||
cat > .env << 'EOF'
|
||||
# 必填:安全密钥(请修改为随机值)
|
||||
JWT_SECRET=your-random-secret-key-at-least-32-chars
|
||||
ENCRYPTION_KEY=your-32-character-encryption-key
|
||||
|
||||
# 可选:管理员凭据
|
||||
ADMIN_USERNAME=cr_admin_custom
|
||||
ADMIN_PASSWORD=your-secure-password
|
||||
EOF
|
||||
|
||||
# 3. 启动服务
|
||||
docker-compose up -d
|
||||
|
||||
# 4. 查看管理员凭据
|
||||
# 自动生成的情况下:
|
||||
docker logs claude-relay-service | grep "管理员"
|
||||
|
||||
# 或者直接查看挂载的文件:
|
||||
cat ./data/init.json
|
||||
```
|
||||
|
||||
### Docker Compose 配置
|
||||
|
||||
docker-compose.yml 已包含:
|
||||
@@ -347,7 +383,7 @@ docker-compose.yml 已包含:
|
||||
|
||||
### 管理员凭据获取方式
|
||||
|
||||
1. **查看容器日志**(推荐)
|
||||
1. **查看容器日志**
|
||||
```bash
|
||||
docker logs claude-relay-service
|
||||
```
|
||||
@@ -426,7 +462,7 @@ claude
|
||||
|
||||
**Claude标准格式:**
|
||||
```
|
||||
# 如果工具支持Claude标准格式 那么推荐使用该接口
|
||||
# 如果工具支持Claude标准格式,请使用该接口
|
||||
http://你的服务器:3000/claude/
|
||||
```
|
||||
|
||||
@@ -588,7 +624,7 @@ redis-cli ping
|
||||
|
||||
**强烈建议使用Caddy反向代理(自动HTTPS)**
|
||||
|
||||
推荐使用Caddy作为反向代理,它会自动申请和更新SSL证书,配置更简单:
|
||||
建议使用Caddy作为反向代理,它会自动申请和更新SSL证书,配置更简单:
|
||||
|
||||
**1. 安装Caddy**
|
||||
```bash
|
||||
|
||||
114
scripts/MANAGE_UPDATE.md
Normal file
114
scripts/MANAGE_UPDATE.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# manage.sh 脚本更新说明
|
||||
|
||||
## 新增功能(最新更新)
|
||||
|
||||
### 1. 端口配置
|
||||
- 安装时会询问服务端口,默认为 3000
|
||||
- 端口配置会自动写入 .env 文件
|
||||
- 检查端口是否被占用并提示
|
||||
|
||||
### 2. 自动启动服务
|
||||
- 安装完成后自动启动服务
|
||||
- 不再需要手动执行 `crs start`
|
||||
|
||||
### 3. 公网 IP 显示
|
||||
- 自动获取公网 IP 地址(通过 https://ipinfo.io/json)
|
||||
- 显示本地访问和公网访问地址
|
||||
- IP 地址缓存 1 小时,避免频繁调用 API
|
||||
|
||||
### 4. 动态端口显示
|
||||
- 所有状态显示都使用实际配置的端口
|
||||
- 交互式菜单显示实际端口和公网地址
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 安装时的新体验
|
||||
```bash
|
||||
$ crs install
|
||||
|
||||
# 会依次询问:
|
||||
安装目录 (默认: ~/claude-relay-service):
|
||||
服务端口 (默认: 3000): 8080
|
||||
Redis 地址 (默认: localhost):
|
||||
Redis 端口 (默认: 6379):
|
||||
Redis 密码 (默认: 无密码):
|
||||
|
||||
# 安装完成后自动启动并显示:
|
||||
服务已成功安装并启动!
|
||||
|
||||
访问地址:
|
||||
本地访问: http://localhost:8080/web
|
||||
公网访问: http://1.2.3.4:8080/web
|
||||
|
||||
管理命令:
|
||||
查看状态: crs status
|
||||
停止服务: crs stop
|
||||
重启服务: crs restart
|
||||
```
|
||||
|
||||
### 状态显示增强
|
||||
```bash
|
||||
$ crs status
|
||||
|
||||
=== Claude Relay Service 状态 ===
|
||||
服务状态: 运行中
|
||||
进程 PID: 12345
|
||||
服务端口: 8080
|
||||
|
||||
访问地址:
|
||||
本地访问: http://localhost:8080/web
|
||||
公网访问: http://1.2.3.4:8080/web
|
||||
API 端点: http://localhost:8080/api/v1
|
||||
|
||||
安装目录: /home/user/claude-relay-service
|
||||
|
||||
Redis 状态:
|
||||
连接状态: 正常
|
||||
```
|
||||
|
||||
## 技术细节
|
||||
|
||||
### 公网 IP 获取
|
||||
- 主要 API: https://ipinfo.io/json
|
||||
- 备用 API: https://api.ipify.org
|
||||
- 缓存文件: /tmp/.crs_public_ip_cache
|
||||
- 缓存时间: 3600 秒(1 小时)
|
||||
|
||||
### 端口配置存储
|
||||
- 配置文件: .env
|
||||
- 环境变量: PORT
|
||||
- 读取优先级: 命令行参数 > .env 文件 > 默认值 3000
|
||||
|
||||
## Redis 安装说明
|
||||
|
||||
### 系统默认安装位置
|
||||
脚本使用系统包管理器安装 Redis,会自动安装到各系统的默认位置:
|
||||
|
||||
- **Debian/Ubuntu**:
|
||||
- 配置文件: `/etc/redis/redis.conf`
|
||||
- 数据目录: `/var/lib/redis`
|
||||
- 日志文件: `/var/log/redis/redis-server.log`
|
||||
- 通过 systemd 管理: `systemctl status redis-server`
|
||||
|
||||
- **RedHat/CentOS**:
|
||||
- 配置文件: `/etc/redis.conf`
|
||||
- 数据目录: `/var/lib/redis`
|
||||
- 日志文件: `/var/log/redis/redis.log`
|
||||
- 通过 systemd 管理: `systemctl status redis`
|
||||
|
||||
- **Arch Linux**:
|
||||
- 配置文件: `/etc/redis/redis.conf`
|
||||
- 数据目录: `/var/lib/redis`
|
||||
- 通过 systemd 管理: `systemctl status redis`
|
||||
|
||||
- **macOS**:
|
||||
- 通过 Homebrew 安装
|
||||
- 配置文件: `/usr/local/etc/redis.conf`
|
||||
- 数据目录: `/usr/local/var/db/redis/`
|
||||
- 通过 brew services 管理: `brew services list`
|
||||
|
||||
### 优势
|
||||
- Redis 数据独立于应用,卸载应用不会丢失数据
|
||||
- 使用系统标准服务管理
|
||||
- 自动开机启动
|
||||
- 系统级的日志和监控
|
||||
1102
scripts/manage.sh
Normal file
1102
scripts/manage.sh
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,159 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* 测试 API Key 过期功能
|
||||
* 快速创建和修改 API Key 过期时间以便测试
|
||||
*/
|
||||
|
||||
const apiKeyService = require('../src/services/apiKeyService');
|
||||
const redis = require('../src/models/redis');
|
||||
const logger = require('../src/utils/logger');
|
||||
const chalk = require('chalk');
|
||||
|
||||
async function createTestApiKeys() {
|
||||
console.log(chalk.bold.blue('\n🧪 创建测试 API Keys\n'));
|
||||
|
||||
try {
|
||||
await redis.connect();
|
||||
|
||||
// 创建不同过期时间的测试 Keys
|
||||
const testKeys = [
|
||||
{
|
||||
name: 'Test-Expired',
|
||||
description: '已过期的测试 Key',
|
||||
expiresAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString() // 1天前过期
|
||||
},
|
||||
{
|
||||
name: 'Test-1Hour',
|
||||
description: '1小时后过期的测试 Key',
|
||||
expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString() // 1小时后
|
||||
},
|
||||
{
|
||||
name: 'Test-1Day',
|
||||
description: '1天后过期的测试 Key',
|
||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() // 1天后
|
||||
},
|
||||
{
|
||||
name: 'Test-7Days',
|
||||
description: '7天后过期的测试 Key',
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString() // 7天后
|
||||
},
|
||||
{
|
||||
name: 'Test-Never',
|
||||
description: '永不过期的测试 Key',
|
||||
expiresAt: null // 永不过期
|
||||
}
|
||||
];
|
||||
|
||||
console.log('正在创建测试 API Keys...\n');
|
||||
|
||||
for (const keyData of testKeys) {
|
||||
try {
|
||||
const newKey = await apiKeyService.generateApiKey(keyData);
|
||||
|
||||
const expiryInfo = keyData.expiresAt
|
||||
? new Date(keyData.expiresAt).toLocaleString()
|
||||
: '永不过期';
|
||||
|
||||
console.log(`✅ 创建成功: ${keyData.name}`);
|
||||
console.log(` API Key: ${newKey.apiKey}`);
|
||||
console.log(` 过期时间: ${expiryInfo}`);
|
||||
console.log('');
|
||||
|
||||
} catch (error) {
|
||||
console.log(chalk.red(`❌ 创建失败: ${keyData.name} - ${error.message}`));
|
||||
}
|
||||
}
|
||||
|
||||
// 运行清理任务测试
|
||||
console.log(chalk.bold.yellow('\n🔄 运行清理任务...\n'));
|
||||
const cleanedCount = await apiKeyService.cleanupExpiredKeys();
|
||||
console.log(`清理了 ${cleanedCount} 个过期的 API Keys\n`);
|
||||
|
||||
// 显示所有 API Keys 状态
|
||||
console.log(chalk.bold.cyan('📊 当前所有 API Keys 状态:\n'));
|
||||
const allKeys = await apiKeyService.getAllApiKeys();
|
||||
|
||||
for (const key of allKeys) {
|
||||
const now = new Date();
|
||||
const expiresAt = key.expiresAt ? new Date(key.expiresAt) : null;
|
||||
let status = '✅ 活跃';
|
||||
let expiryInfo = '永不过期';
|
||||
|
||||
if (expiresAt) {
|
||||
if (expiresAt < now) {
|
||||
status = '❌ 已过期';
|
||||
expiryInfo = `过期于 ${expiresAt.toLocaleString()}`;
|
||||
} else {
|
||||
const hoursLeft = Math.ceil((expiresAt - now) / (1000 * 60 * 60));
|
||||
const daysLeft = Math.ceil(hoursLeft / 24);
|
||||
|
||||
if (hoursLeft < 24) {
|
||||
expiryInfo = chalk.yellow(`${hoursLeft}小时后过期`);
|
||||
} else if (daysLeft <= 7) {
|
||||
expiryInfo = chalk.yellow(`${daysLeft}天后过期`);
|
||||
} else {
|
||||
expiryInfo = chalk.green(`${daysLeft}天后过期`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!key.isActive) {
|
||||
status = '🔒 已禁用';
|
||||
}
|
||||
|
||||
console.log(`${status} ${key.name} - ${expiryInfo}`);
|
||||
console.log(` API Key: ${key.apiKey?.substring(0, 30)}...`);
|
||||
console.log('');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(chalk.red('测试失败:'), error);
|
||||
} finally {
|
||||
await redis.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
// 主函数
|
||||
async function main() {
|
||||
console.log(chalk.bold.magenta('\n===================================='));
|
||||
console.log(chalk.bold.magenta(' API Key 过期功能测试工具'));
|
||||
console.log(chalk.bold.magenta('====================================\n'));
|
||||
|
||||
console.log('此工具将:');
|
||||
console.log('1. 创建不同过期时间的测试 API Keys');
|
||||
console.log('2. 运行清理任务禁用过期的 Keys');
|
||||
console.log('3. 显示所有 Keys 的当前状态\n');
|
||||
|
||||
console.log(chalk.yellow('⚠️ 注意:这会在您的系统中创建真实的 API Keys\n'));
|
||||
|
||||
const readline = require('readline').createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
});
|
||||
|
||||
readline.question('是否继续?(y/n): ', async (answer) => {
|
||||
if (answer.toLowerCase() === 'y') {
|
||||
await createTestApiKeys();
|
||||
|
||||
console.log(chalk.bold.green('\n✅ 测试完成!\n'));
|
||||
console.log('您现在可以:');
|
||||
console.log('1. 使用 CLI 工具管理这些测试 Keys:');
|
||||
console.log(' npm run cli keys');
|
||||
console.log('');
|
||||
console.log('2. 在 Web 界面查看和管理这些 Keys');
|
||||
console.log('');
|
||||
console.log('3. 测试 API 调用时的过期验证');
|
||||
} else {
|
||||
console.log('\n已取消');
|
||||
}
|
||||
|
||||
readline.close();
|
||||
});
|
||||
}
|
||||
|
||||
// 运行
|
||||
main().catch(error => {
|
||||
console.error(chalk.red('错误:'), error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// 测试Claude Console账号URL处理
|
||||
|
||||
const testUrls = [
|
||||
'https://api.example.com',
|
||||
'https://api.example.com/',
|
||||
'https://api.example.com/v1/messages',
|
||||
'https://api.example.com/v1/messages/',
|
||||
'https://api.example.com:8080',
|
||||
'https://api.example.com:8080/v1/messages'
|
||||
];
|
||||
|
||||
console.log('🧪 Testing Claude Console URL handling:\n');
|
||||
|
||||
testUrls.forEach(url => {
|
||||
// 模拟账号服务的URL处理逻辑
|
||||
const cleanUrl = url.replace(/\/$/, ''); // 移除末尾斜杠
|
||||
const apiEndpoint = cleanUrl.endsWith('/v1/messages')
|
||||
? cleanUrl
|
||||
: `${cleanUrl}/v1/messages`;
|
||||
|
||||
console.log(`Input: ${url}`);
|
||||
console.log(`Output: ${apiEndpoint}`);
|
||||
console.log('---');
|
||||
});
|
||||
|
||||
console.log('\n✅ URL normalization logic test completed');
|
||||
@@ -1,102 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* 测试 Gemini 账户解密
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const dotenv = require('dotenv');
|
||||
const crypto = require('crypto');
|
||||
|
||||
// 加载环境变量
|
||||
dotenv.config({ path: path.join(__dirname, '..', '.env') });
|
||||
|
||||
const redis = require('../src/models/redis');
|
||||
const config = require('../config/config');
|
||||
|
||||
const ALGORITHM = 'aes-256-cbc';
|
||||
const ENCRYPTION_SALT = 'gemini-account-salt'; // 正确的盐值!
|
||||
const GEMINI_ACCOUNT_KEY_PREFIX = 'gemini_account:';
|
||||
|
||||
// 生成加密密钥(与 geminiAccountService 完全相同)
|
||||
function generateEncryptionKey() {
|
||||
return crypto.scryptSync(config.security.encryptionKey, ENCRYPTION_SALT, 32);
|
||||
}
|
||||
|
||||
// 解密函数(与 geminiAccountService 相同)
|
||||
function decrypt(text) {
|
||||
if (!text) return '';
|
||||
try {
|
||||
const key = generateEncryptionKey();
|
||||
// IV 是固定长度的 32 个十六进制字符(16 字节)
|
||||
const ivHex = text.substring(0, 32);
|
||||
const encryptedHex = text.substring(33); // 跳过冒号
|
||||
|
||||
const iv = Buffer.from(ivHex, 'hex');
|
||||
const encryptedText = Buffer.from(encryptedHex, 'hex');
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
||||
let decrypted = decipher.update(encryptedText);
|
||||
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
||||
return decrypted.toString();
|
||||
} catch (error) {
|
||||
console.error('解密错误:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function testDecrypt() {
|
||||
try {
|
||||
console.log('🚀 测试 Gemini 账户解密...\n');
|
||||
|
||||
console.log('📋 加密配置:');
|
||||
console.log(` config.security.encryptionKey: ${config.security.encryptionKey}`);
|
||||
console.log(` ENCRYPTION_SALT: ${ENCRYPTION_SALT}`);
|
||||
console.log();
|
||||
|
||||
// 连接 Redis
|
||||
console.log('📡 连接 Redis...');
|
||||
await redis.connect();
|
||||
console.log('✅ Redis 连接成功\n');
|
||||
|
||||
const client = redis.getClient();
|
||||
const keys = await client.keys(`${GEMINI_ACCOUNT_KEY_PREFIX}*`);
|
||||
|
||||
if (keys.length === 0) {
|
||||
console.log('❌ 没有找到 Gemini 账户');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`🔍 找到 ${keys.length} 个 Gemini 账户\n`);
|
||||
|
||||
for (const key of keys) {
|
||||
const accountData = await client.hgetall(key);
|
||||
const accountId = key.replace(GEMINI_ACCOUNT_KEY_PREFIX, '');
|
||||
|
||||
console.log(`📋 账户: ${accountData.name} (${accountId})`);
|
||||
|
||||
if (accountData.refreshToken) {
|
||||
console.log('🔐 尝试解密 refreshToken...');
|
||||
const decrypted = decrypt(accountData.refreshToken);
|
||||
|
||||
if (decrypted) {
|
||||
console.log('✅ 解密成功!');
|
||||
console.log(` Token 前缀: ${decrypted.substring(0, 20)}...`);
|
||||
} else {
|
||||
console.log('❌ 解密失败');
|
||||
}
|
||||
} else {
|
||||
console.log('⚠️ 无 refreshToken');
|
||||
}
|
||||
|
||||
console.log();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 测试失败:', error);
|
||||
} finally {
|
||||
await redis.disconnect();
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
testDecrypt();
|
||||
545
scripts/test-group-scheduling.js
Normal file
545
scripts/test-group-scheduling.js
Normal file
@@ -0,0 +1,545 @@
|
||||
/**
|
||||
* 分组调度功能测试脚本
|
||||
* 用于测试账户分组管理和调度逻辑的正确性
|
||||
*/
|
||||
|
||||
require('dotenv').config();
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const redis = require('../src/models/redis');
|
||||
const accountGroupService = require('../src/services/accountGroupService');
|
||||
const claudeAccountService = require('../src/services/claudeAccountService');
|
||||
const claudeConsoleAccountService = require('../src/services/claudeConsoleAccountService');
|
||||
const apiKeyService = require('../src/services/apiKeyService');
|
||||
const unifiedClaudeScheduler = require('../src/services/unifiedClaudeScheduler');
|
||||
const logger = require('../src/utils/logger');
|
||||
|
||||
// 测试配置
|
||||
const TEST_PREFIX = 'test_group_';
|
||||
const CLEANUP_ON_FINISH = true; // 测试完成后是否清理数据
|
||||
|
||||
// 测试数据存储
|
||||
const testData = {
|
||||
groups: [],
|
||||
accounts: [],
|
||||
apiKeys: []
|
||||
};
|
||||
|
||||
// 颜色输出
|
||||
const colors = {
|
||||
green: '\x1b[32m',
|
||||
red: '\x1b[31m',
|
||||
yellow: '\x1b[33m',
|
||||
blue: '\x1b[34m',
|
||||
reset: '\x1b[0m'
|
||||
};
|
||||
|
||||
function log(message, type = 'info') {
|
||||
const color = {
|
||||
success: colors.green,
|
||||
error: colors.red,
|
||||
warning: colors.yellow,
|
||||
info: colors.blue
|
||||
}[type] || colors.reset;
|
||||
|
||||
console.log(`${color}${message}${colors.reset}`);
|
||||
}
|
||||
|
||||
async function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// 清理测试数据
|
||||
async function cleanup() {
|
||||
log('\n🧹 清理测试数据...', 'info');
|
||||
|
||||
// 删除测试API Keys
|
||||
for (const apiKey of testData.apiKeys) {
|
||||
try {
|
||||
await apiKeyService.deleteApiKey(apiKey.id);
|
||||
log(`✅ 删除测试API Key: ${apiKey.name}`, 'success');
|
||||
} catch (error) {
|
||||
log(`❌ 删除API Key失败: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 删除测试账户
|
||||
for (const account of testData.accounts) {
|
||||
try {
|
||||
if (account.type === 'claude') {
|
||||
await claudeAccountService.deleteAccount(account.id);
|
||||
} else if (account.type === 'claude-console') {
|
||||
await claudeConsoleAccountService.deleteAccount(account.id);
|
||||
}
|
||||
log(`✅ 删除测试账户: ${account.name}`, 'success');
|
||||
} catch (error) {
|
||||
log(`❌ 删除账户失败: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 删除测试分组
|
||||
for (const group of testData.groups) {
|
||||
try {
|
||||
await accountGroupService.deleteGroup(group.id);
|
||||
log(`✅ 删除测试分组: ${group.name}`, 'success');
|
||||
} catch (error) {
|
||||
// 可能因为还有成员而删除失败,先移除所有成员
|
||||
if (error.message.includes('分组内还有账户')) {
|
||||
const members = await accountGroupService.getGroupMembers(group.id);
|
||||
for (const memberId of members) {
|
||||
await accountGroupService.removeAccountFromGroup(memberId, group.id);
|
||||
}
|
||||
// 重试删除
|
||||
await accountGroupService.deleteGroup(group.id);
|
||||
log(`✅ 删除测试分组: ${group.name} (清空成员后)`, 'success');
|
||||
} else {
|
||||
log(`❌ 删除分组失败: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 测试1: 创建分组
|
||||
async function test1_createGroups() {
|
||||
log('\n📝 测试1: 创建账户分组', 'info');
|
||||
|
||||
try {
|
||||
// 创建Claude分组
|
||||
const claudeGroup = await accountGroupService.createGroup({
|
||||
name: TEST_PREFIX + 'Claude组',
|
||||
platform: 'claude',
|
||||
description: '测试用Claude账户分组'
|
||||
});
|
||||
testData.groups.push(claudeGroup);
|
||||
log(`✅ 创建Claude分组成功: ${claudeGroup.name} (ID: ${claudeGroup.id})`, 'success');
|
||||
|
||||
// 创建Gemini分组
|
||||
const geminiGroup = await accountGroupService.createGroup({
|
||||
name: TEST_PREFIX + 'Gemini组',
|
||||
platform: 'gemini',
|
||||
description: '测试用Gemini账户分组'
|
||||
});
|
||||
testData.groups.push(geminiGroup);
|
||||
log(`✅ 创建Gemini分组成功: ${geminiGroup.name} (ID: ${geminiGroup.id})`, 'success');
|
||||
|
||||
// 验证分组信息
|
||||
const allGroups = await accountGroupService.getAllGroups();
|
||||
const testGroups = allGroups.filter(g => g.name.startsWith(TEST_PREFIX));
|
||||
|
||||
if (testGroups.length === 2) {
|
||||
log(`✅ 分组创建验证通过,共创建 ${testGroups.length} 个测试分组`, 'success');
|
||||
} else {
|
||||
throw new Error(`分组数量不正确,期望2个,实际${testGroups.length}个`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
log(`❌ 测试1失败: ${error.message}`, 'error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 测试2: 创建账户并添加到分组
|
||||
async function test2_createAccountsAndAddToGroup() {
|
||||
log('\n📝 测试2: 创建账户并添加到分组', 'info');
|
||||
|
||||
try {
|
||||
const claudeGroup = testData.groups.find(g => g.platform === 'claude');
|
||||
|
||||
// 创建Claude OAuth账户
|
||||
const claudeAccount1 = await claudeAccountService.createAccount({
|
||||
name: TEST_PREFIX + 'Claude账户1',
|
||||
email: 'test1@example.com',
|
||||
refreshToken: 'test_refresh_token_1',
|
||||
accountType: 'group'
|
||||
});
|
||||
testData.accounts.push({ ...claudeAccount1, type: 'claude' });
|
||||
log(`✅ 创建Claude OAuth账户1成功: ${claudeAccount1.name}`, 'success');
|
||||
|
||||
const claudeAccount2 = await claudeAccountService.createAccount({
|
||||
name: TEST_PREFIX + 'Claude账户2',
|
||||
email: 'test2@example.com',
|
||||
refreshToken: 'test_refresh_token_2',
|
||||
accountType: 'group'
|
||||
});
|
||||
testData.accounts.push({ ...claudeAccount2, type: 'claude' });
|
||||
log(`✅ 创建Claude OAuth账户2成功: ${claudeAccount2.name}`, 'success');
|
||||
|
||||
// 创建Claude Console账户
|
||||
const consoleAccount = await claudeConsoleAccountService.createAccount({
|
||||
name: TEST_PREFIX + 'Console账户',
|
||||
apiUrl: 'https://api.example.com',
|
||||
apiKey: 'test_api_key',
|
||||
accountType: 'group'
|
||||
});
|
||||
testData.accounts.push({ ...consoleAccount, type: 'claude-console' });
|
||||
log(`✅ 创建Claude Console账户成功: ${consoleAccount.name}`, 'success');
|
||||
|
||||
// 添加账户到分组
|
||||
await accountGroupService.addAccountToGroup(claudeAccount1.id, claudeGroup.id, 'claude');
|
||||
log(`✅ 添加账户1到分组成功`, 'success');
|
||||
|
||||
await accountGroupService.addAccountToGroup(claudeAccount2.id, claudeGroup.id, 'claude');
|
||||
log(`✅ 添加账户2到分组成功`, 'success');
|
||||
|
||||
await accountGroupService.addAccountToGroup(consoleAccount.id, claudeGroup.id, 'claude');
|
||||
log(`✅ 添加Console账户到分组成功`, 'success');
|
||||
|
||||
// 验证分组成员
|
||||
const members = await accountGroupService.getGroupMembers(claudeGroup.id);
|
||||
if (members.length === 3) {
|
||||
log(`✅ 分组成员验证通过,共有 ${members.length} 个成员`, 'success');
|
||||
} else {
|
||||
throw new Error(`分组成员数量不正确,期望3个,实际${members.length}个`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
log(`❌ 测试2失败: ${error.message}`, 'error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 测试3: 平台一致性验证
|
||||
async function test3_platformConsistency() {
|
||||
log('\n📝 测试3: 平台一致性验证', 'info');
|
||||
|
||||
try {
|
||||
const claudeGroup = testData.groups.find(g => g.platform === 'claude');
|
||||
const geminiGroup = testData.groups.find(g => g.platform === 'gemini');
|
||||
|
||||
// 尝试将Claude账户添加到Gemini分组(应该失败)
|
||||
const claudeAccount = testData.accounts.find(a => a.type === 'claude');
|
||||
|
||||
try {
|
||||
await accountGroupService.addAccountToGroup(claudeAccount.id, geminiGroup.id, 'claude');
|
||||
throw new Error('平台验证失败:Claude账户不应该能添加到Gemini分组');
|
||||
} catch (error) {
|
||||
if (error.message.includes('平台与分组平台不匹配')) {
|
||||
log(`✅ 平台一致性验证通过:${error.message}`, 'success');
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
log(`❌ 测试3失败: ${error.message}`, 'error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 测试4: API Key绑定分组
|
||||
async function test4_apiKeyBindGroup() {
|
||||
log('\n📝 测试4: API Key绑定分组', 'info');
|
||||
|
||||
try {
|
||||
const claudeGroup = testData.groups.find(g => g.platform === 'claude');
|
||||
|
||||
// 创建绑定到分组的API Key
|
||||
const apiKey = await apiKeyService.generateApiKey({
|
||||
name: TEST_PREFIX + 'API Key',
|
||||
description: '测试分组调度的API Key',
|
||||
claudeAccountId: `group:${claudeGroup.id}`,
|
||||
permissions: 'claude'
|
||||
});
|
||||
testData.apiKeys.push(apiKey);
|
||||
log(`✅ 创建API Key成功: ${apiKey.name} (绑定到分组: ${claudeGroup.name})`, 'success');
|
||||
|
||||
// 验证API Key信息
|
||||
const keyInfo = await redis.getApiKey(apiKey.id);
|
||||
if (keyInfo && keyInfo.claudeAccountId === `group:${claudeGroup.id}`) {
|
||||
log(`✅ API Key分组绑定验证通过`, 'success');
|
||||
} else {
|
||||
throw new Error('API Key分组绑定信息不正确');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
log(`❌ 测试4失败: ${error.message}`, 'error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 测试5: 分组调度负载均衡
|
||||
async function test5_groupSchedulingLoadBalance() {
|
||||
log('\n📝 测试5: 分组调度负载均衡', 'info');
|
||||
|
||||
try {
|
||||
const claudeGroup = testData.groups.find(g => g.platform === 'claude');
|
||||
const apiKey = testData.apiKeys[0];
|
||||
|
||||
// 记录每个账户被选中的次数
|
||||
const selectionCount = {};
|
||||
const totalSelections = 30;
|
||||
|
||||
for (let i = 0; i < totalSelections; i++) {
|
||||
// 模拟不同的会话
|
||||
const sessionHash = uuidv4();
|
||||
|
||||
const result = await unifiedClaudeScheduler.selectAccountForApiKey({
|
||||
id: apiKey.id,
|
||||
claudeAccountId: apiKey.claudeAccountId,
|
||||
name: apiKey.name
|
||||
}, sessionHash);
|
||||
|
||||
if (!selectionCount[result.accountId]) {
|
||||
selectionCount[result.accountId] = 0;
|
||||
}
|
||||
selectionCount[result.accountId]++;
|
||||
|
||||
// 短暂延迟,模拟真实请求间隔
|
||||
await sleep(50);
|
||||
}
|
||||
|
||||
// 分析选择分布
|
||||
log(`\n📊 负载均衡分布统计 (共${totalSelections}次选择):`, 'info');
|
||||
const accounts = Object.keys(selectionCount);
|
||||
|
||||
for (const accountId of accounts) {
|
||||
const count = selectionCount[accountId];
|
||||
const percentage = ((count / totalSelections) * 100).toFixed(1);
|
||||
const accountInfo = testData.accounts.find(a => a.id === accountId);
|
||||
log(` ${accountInfo.name}: ${count}次 (${percentage}%)`, 'info');
|
||||
}
|
||||
|
||||
// 验证是否实现了负载均衡
|
||||
const counts = Object.values(selectionCount);
|
||||
const avgCount = totalSelections / accounts.length;
|
||||
const variance = counts.reduce((sum, count) => sum + Math.pow(count - avgCount, 2), 0) / counts.length;
|
||||
const stdDev = Math.sqrt(variance);
|
||||
|
||||
log(`\n 平均选择次数: ${avgCount.toFixed(1)}`, 'info');
|
||||
log(` 标准差: ${stdDev.toFixed(1)}`, 'info');
|
||||
|
||||
// 如果标准差小于平均值的50%,认为负载均衡效果良好
|
||||
if (stdDev < avgCount * 0.5) {
|
||||
log(`✅ 负载均衡验证通过,分布相对均匀`, 'success');
|
||||
} else {
|
||||
log(`⚠️ 负载分布不够均匀,但这可能是正常的随机波动`, 'warning');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
log(`❌ 测试5失败: ${error.message}`, 'error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 测试6: 会话粘性测试
|
||||
async function test6_stickySession() {
|
||||
log('\n📝 测试6: 会话粘性(Sticky Session)测试', 'info');
|
||||
|
||||
try {
|
||||
const apiKey = testData.apiKeys[0];
|
||||
const sessionHash = 'test_session_' + uuidv4();
|
||||
|
||||
// 第一次选择
|
||||
const firstSelection = await unifiedClaudeScheduler.selectAccountForApiKey({
|
||||
id: apiKey.id,
|
||||
claudeAccountId: apiKey.claudeAccountId,
|
||||
name: apiKey.name
|
||||
}, sessionHash);
|
||||
|
||||
log(` 首次选择账户: ${firstSelection.accountId}`, 'info');
|
||||
|
||||
// 使用相同的sessionHash多次请求
|
||||
let consistentCount = 0;
|
||||
const testCount = 10;
|
||||
|
||||
for (let i = 0; i < testCount; i++) {
|
||||
const selection = await unifiedClaudeScheduler.selectAccountForApiKey({
|
||||
id: apiKey.id,
|
||||
claudeAccountId: apiKey.claudeAccountId,
|
||||
name: apiKey.name
|
||||
}, sessionHash);
|
||||
|
||||
if (selection.accountId === firstSelection.accountId) {
|
||||
consistentCount++;
|
||||
}
|
||||
|
||||
await sleep(100);
|
||||
}
|
||||
|
||||
log(` 会话一致性: ${consistentCount}/${testCount} 次选择了相同账户`, 'info');
|
||||
|
||||
if (consistentCount === testCount) {
|
||||
log(`✅ 会话粘性验证通过,同一会话始终选择相同账户`, 'success');
|
||||
} else {
|
||||
throw new Error(`会话粘性失败,只有${consistentCount}/${testCount}次选择了相同账户`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
log(`❌ 测试6失败: ${error.message}`, 'error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 测试7: 账户可用性检查
|
||||
async function test7_accountAvailability() {
|
||||
log('\n📝 测试7: 账户可用性检查', 'info');
|
||||
|
||||
try {
|
||||
const apiKey = testData.apiKeys[0];
|
||||
const accounts = testData.accounts.filter(a => a.type === 'claude' || a.type === 'claude-console');
|
||||
|
||||
// 禁用第一个账户
|
||||
const firstAccount = accounts[0];
|
||||
if (firstAccount.type === 'claude') {
|
||||
await claudeAccountService.updateAccount(firstAccount.id, { isActive: false });
|
||||
} else {
|
||||
await claudeConsoleAccountService.updateAccount(firstAccount.id, { isActive: false });
|
||||
}
|
||||
log(` 已禁用账户: ${firstAccount.name}`, 'info');
|
||||
|
||||
// 多次选择,验证不会选择到禁用的账户
|
||||
const selectionResults = [];
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const sessionHash = uuidv4(); // 每次使用新会话
|
||||
const result = await unifiedClaudeScheduler.selectAccountForApiKey({
|
||||
id: apiKey.id,
|
||||
claudeAccountId: apiKey.claudeAccountId,
|
||||
name: apiKey.name
|
||||
}, sessionHash);
|
||||
|
||||
selectionResults.push(result.accountId);
|
||||
}
|
||||
|
||||
// 检查是否选择了禁用的账户
|
||||
const selectedDisabled = selectionResults.includes(firstAccount.id);
|
||||
|
||||
if (!selectedDisabled) {
|
||||
log(`✅ 账户可用性验证通过,未选择禁用的账户`, 'success');
|
||||
} else {
|
||||
throw new Error('错误:选择了已禁用的账户');
|
||||
}
|
||||
|
||||
// 重新启用账户
|
||||
if (firstAccount.type === 'claude') {
|
||||
await claudeAccountService.updateAccount(firstAccount.id, { isActive: true });
|
||||
} else {
|
||||
await claudeConsoleAccountService.updateAccount(firstAccount.id, { isActive: true });
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
log(`❌ 测试7失败: ${error.message}`, 'error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 测试8: 分组成员管理
|
||||
async function test8_groupMemberManagement() {
|
||||
log('\n📝 测试8: 分组成员管理', 'info');
|
||||
|
||||
try {
|
||||
const claudeGroup = testData.groups.find(g => g.platform === 'claude');
|
||||
const account = testData.accounts.find(a => a.type === 'claude');
|
||||
|
||||
// 获取账户所属分组
|
||||
const accountGroup = await accountGroupService.getAccountGroup(account.id);
|
||||
if (accountGroup && accountGroup.id === claudeGroup.id) {
|
||||
log(`✅ 账户分组查询验证通过`, 'success');
|
||||
} else {
|
||||
throw new Error('账户分组查询结果不正确');
|
||||
}
|
||||
|
||||
// 从分组移除账户
|
||||
await accountGroupService.removeAccountFromGroup(account.id, claudeGroup.id);
|
||||
log(` 从分组移除账户: ${account.name}`, 'info');
|
||||
|
||||
// 验证账户已不在分组中
|
||||
const membersAfterRemove = await accountGroupService.getGroupMembers(claudeGroup.id);
|
||||
if (!membersAfterRemove.includes(account.id)) {
|
||||
log(`✅ 账户移除验证通过`, 'success');
|
||||
} else {
|
||||
throw new Error('账户移除失败');
|
||||
}
|
||||
|
||||
// 重新添加账户
|
||||
await accountGroupService.addAccountToGroup(account.id, claudeGroup.id, 'claude');
|
||||
log(` 重新添加账户到分组`, 'info');
|
||||
|
||||
} catch (error) {
|
||||
log(`❌ 测试8失败: ${error.message}`, 'error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 测试9: 空分组处理
|
||||
async function test9_emptyGroupHandling() {
|
||||
log('\n📝 测试9: 空分组处理', 'info');
|
||||
|
||||
try {
|
||||
// 创建一个空分组
|
||||
const emptyGroup = await accountGroupService.createGroup({
|
||||
name: TEST_PREFIX + '空分组',
|
||||
platform: 'claude',
|
||||
description: '测试空分组'
|
||||
});
|
||||
testData.groups.push(emptyGroup);
|
||||
|
||||
// 创建绑定到空分组的API Key
|
||||
const apiKey = await apiKeyService.generateApiKey({
|
||||
name: TEST_PREFIX + '空分组API Key',
|
||||
claudeAccountId: `group:${emptyGroup.id}`,
|
||||
permissions: 'claude'
|
||||
});
|
||||
testData.apiKeys.push(apiKey);
|
||||
|
||||
// 尝试从空分组选择账户(应该失败)
|
||||
try {
|
||||
await unifiedClaudeScheduler.selectAccountForApiKey({
|
||||
id: apiKey.id,
|
||||
claudeAccountId: apiKey.claudeAccountId,
|
||||
name: apiKey.name
|
||||
});
|
||||
throw new Error('空分组选择账户应该失败');
|
||||
} catch (error) {
|
||||
if (error.message.includes('has no members')) {
|
||||
log(`✅ 空分组处理验证通过:${error.message}`, 'success');
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
log(`❌ 测试9失败: ${error.message}`, 'error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 主测试函数
|
||||
async function runTests() {
|
||||
log('\n🚀 开始分组调度功能测试\n', 'info');
|
||||
|
||||
try {
|
||||
// 连接Redis
|
||||
await redis.connect();
|
||||
log('✅ Redis连接成功', 'success');
|
||||
|
||||
// 执行测试
|
||||
await test1_createGroups();
|
||||
await test2_createAccountsAndAddToGroup();
|
||||
await test3_platformConsistency();
|
||||
await test4_apiKeyBindGroup();
|
||||
await test5_groupSchedulingLoadBalance();
|
||||
await test6_stickySession();
|
||||
await test7_accountAvailability();
|
||||
await test8_groupMemberManagement();
|
||||
await test9_emptyGroupHandling();
|
||||
|
||||
log('\n🎉 所有测试通过!分组调度功能工作正常', 'success');
|
||||
|
||||
} catch (error) {
|
||||
log(`\n❌ 测试失败: ${error.message}`, 'error');
|
||||
console.error(error);
|
||||
} finally {
|
||||
// 清理测试数据
|
||||
if (CLEANUP_ON_FINISH) {
|
||||
await cleanup();
|
||||
} else {
|
||||
log('\n⚠️ 测试数据未清理,请手动清理', 'warning');
|
||||
}
|
||||
|
||||
// 关闭Redis连接
|
||||
await redis.disconnect();
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
// 运行测试
|
||||
runTests();
|
||||
@@ -1,181 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* 测试导入加密处理
|
||||
* 验证增强版数据传输工具是否正确处理加密和未加密的导出数据
|
||||
*/
|
||||
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const config = require('../config/config');
|
||||
const logger = require('../src/utils/logger');
|
||||
|
||||
// 模拟加密函数
|
||||
function encryptData(data, salt = 'salt') {
|
||||
if (!data || !config.security.encryptionKey) return data;
|
||||
|
||||
const key = crypto.scryptSync(config.security.encryptionKey, salt, 32);
|
||||
const iv = crypto.randomBytes(16);
|
||||
|
||||
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
|
||||
let encrypted = cipher.update(data, 'utf8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
|
||||
return iv.toString('hex') + ':' + encrypted;
|
||||
}
|
||||
|
||||
// 模拟解密函数
|
||||
function decryptData(encryptedData, salt = 'salt') {
|
||||
if (!encryptedData || !config.security.encryptionKey) return encryptedData;
|
||||
|
||||
try {
|
||||
if (encryptedData.includes(':')) {
|
||||
const parts = encryptedData.split(':');
|
||||
const key = crypto.scryptSync(config.security.encryptionKey, salt, 32);
|
||||
const iv = Buffer.from(parts[0], 'hex');
|
||||
const encrypted = parts[1];
|
||||
|
||||
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
|
||||
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
return decrypted;
|
||||
}
|
||||
return encryptedData;
|
||||
} catch (error) {
|
||||
logger.warn(`⚠️ Failed to decrypt data: ${error.message}`);
|
||||
return encryptedData;
|
||||
}
|
||||
}
|
||||
|
||||
async function testImportHandling() {
|
||||
console.log('🧪 测试导入加密处理\n');
|
||||
|
||||
// 测试数据
|
||||
const testClaudeAccount = {
|
||||
id: 'test-claude-123',
|
||||
name: 'Test Claude Account',
|
||||
email: 'test@example.com',
|
||||
password: 'testPassword123',
|
||||
accessToken: 'test-access-token',
|
||||
refreshToken: 'test-refresh-token',
|
||||
claudeAiOauth: {
|
||||
access_token: 'oauth-access-token',
|
||||
refresh_token: 'oauth-refresh-token',
|
||||
scopes: ['read', 'write']
|
||||
}
|
||||
};
|
||||
|
||||
const testGeminiAccount = {
|
||||
id: 'test-gemini-456',
|
||||
name: 'Test Gemini Account',
|
||||
geminiOauth: {
|
||||
access_token: 'gemini-access-token',
|
||||
refresh_token: 'gemini-refresh-token'
|
||||
},
|
||||
accessToken: 'gemini-access-token',
|
||||
refreshToken: 'gemini-refresh-token'
|
||||
};
|
||||
|
||||
// 1. 创建解密的导出文件(模拟 --decrypt=true)
|
||||
const decryptedExport = {
|
||||
metadata: {
|
||||
version: '2.0',
|
||||
exportDate: new Date().toISOString(),
|
||||
sanitized: false,
|
||||
decrypted: true, // 标记为已解密
|
||||
types: ['all']
|
||||
},
|
||||
data: {
|
||||
claudeAccounts: [testClaudeAccount],
|
||||
geminiAccounts: [testGeminiAccount]
|
||||
}
|
||||
};
|
||||
|
||||
// 2. 创建加密的导出文件(模拟 --decrypt=false)
|
||||
const encryptedClaudeAccount = { ...testClaudeAccount };
|
||||
encryptedClaudeAccount.email = encryptData(encryptedClaudeAccount.email);
|
||||
encryptedClaudeAccount.password = encryptData(encryptedClaudeAccount.password);
|
||||
encryptedClaudeAccount.accessToken = encryptData(encryptedClaudeAccount.accessToken);
|
||||
encryptedClaudeAccount.refreshToken = encryptData(encryptedClaudeAccount.refreshToken);
|
||||
encryptedClaudeAccount.claudeAiOauth = encryptData(JSON.stringify(encryptedClaudeAccount.claudeAiOauth));
|
||||
|
||||
const encryptedGeminiAccount = { ...testGeminiAccount };
|
||||
encryptedGeminiAccount.geminiOauth = encryptData(JSON.stringify(encryptedGeminiAccount.geminiOauth), 'gemini-account-salt');
|
||||
encryptedGeminiAccount.accessToken = encryptData(encryptedGeminiAccount.accessToken, 'gemini-account-salt');
|
||||
encryptedGeminiAccount.refreshToken = encryptData(encryptedGeminiAccount.refreshToken, 'gemini-account-salt');
|
||||
|
||||
const encryptedExport = {
|
||||
metadata: {
|
||||
version: '2.0',
|
||||
exportDate: new Date().toISOString(),
|
||||
sanitized: false,
|
||||
decrypted: false, // 标记为未解密(加密状态)
|
||||
types: ['all']
|
||||
},
|
||||
data: {
|
||||
claudeAccounts: [encryptedClaudeAccount],
|
||||
geminiAccounts: [encryptedGeminiAccount]
|
||||
}
|
||||
};
|
||||
|
||||
// 写入测试文件
|
||||
const testDir = path.join(__dirname, '../data/test-imports');
|
||||
await fs.mkdir(testDir, { recursive: true });
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(testDir, 'decrypted-export.json'),
|
||||
JSON.stringify(decryptedExport, null, 2)
|
||||
);
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(testDir, 'encrypted-export.json'),
|
||||
JSON.stringify(encryptedExport, null, 2)
|
||||
);
|
||||
|
||||
console.log('✅ 测试文件已创建:');
|
||||
console.log(' - data/test-imports/decrypted-export.json (解密的数据)');
|
||||
console.log(' - data/test-imports/encrypted-export.json (加密的数据)\n');
|
||||
|
||||
console.log('📋 测试场景:\n');
|
||||
|
||||
console.log('1. 导入解密的数据(decrypted=true):');
|
||||
console.log(' - 导入时应该重新加密敏感字段');
|
||||
console.log(' - 命令: npm run data:import:enhanced -- --input=data/test-imports/decrypted-export.json\n');
|
||||
|
||||
console.log('2. 导入加密的数据(decrypted=false):');
|
||||
console.log(' - 导入时应该保持原样(已经是加密的)');
|
||||
console.log(' - 命令: npm run data:import:enhanced -- --input=data/test-imports/encrypted-export.json\n');
|
||||
|
||||
console.log('3. 验证导入后的数据:');
|
||||
console.log(' - 使用 CLI 查看账户状态');
|
||||
console.log(' - 命令: npm run cli accounts list\n');
|
||||
|
||||
// 显示示例数据对比
|
||||
console.log('📊 数据对比示例:\n');
|
||||
console.log('原始数据(解密状态):');
|
||||
console.log(` email: "${testClaudeAccount.email}"`);
|
||||
console.log(` password: "${testClaudeAccount.password}"`);
|
||||
console.log(` accessToken: "${testClaudeAccount.accessToken}"\n`);
|
||||
|
||||
console.log('加密后的数据:');
|
||||
console.log(` email: "${encryptedClaudeAccount.email.substring(0, 50)}..."`);
|
||||
console.log(` password: "${encryptedClaudeAccount.password.substring(0, 50)}..."`);
|
||||
console.log(` accessToken: "${encryptedClaudeAccount.accessToken.substring(0, 50)}..."\n`);
|
||||
|
||||
// 验证加密/解密
|
||||
console.log('🔐 验证加密/解密功能:');
|
||||
const testString = 'test-data-123';
|
||||
const encrypted = encryptData(testString);
|
||||
const decrypted = decryptData(encrypted);
|
||||
console.log(` 原始: "${testString}"`);
|
||||
console.log(` 加密: "${encrypted.substring(0, 50)}..."`);
|
||||
console.log(` 解密: "${decrypted}"`);
|
||||
console.log(` 验证: ${testString === decrypted ? '✅ 成功' : '❌ 失败'}\n`);
|
||||
}
|
||||
|
||||
// 运行测试
|
||||
testImportHandling().catch(error => {
|
||||
console.error('❌ 测试失败:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
86
src/app.js
86
src/app.js
@@ -67,6 +67,24 @@ class Application {
|
||||
const claudeAccountService = require('./services/claudeAccountService');
|
||||
await claudeAccountService.initializeSessionWindows();
|
||||
|
||||
// 超早期拦截 /admin-next/ 请求 - 在所有中间件之前
|
||||
this.app.use((req, res, next) => {
|
||||
if (req.path === '/admin-next/' && req.method === 'GET') {
|
||||
logger.warn(`🚨 INTERCEPTING /admin-next/ request at the very beginning!`);
|
||||
const adminSpaPath = path.join(__dirname, '..', 'web', 'admin-spa', 'dist');
|
||||
const indexPath = path.join(adminSpaPath, 'index.html');
|
||||
|
||||
if (fs.existsSync(indexPath)) {
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
return res.sendFile(indexPath);
|
||||
} else {
|
||||
logger.error('❌ index.html not found at:', indexPath);
|
||||
return res.status(404).send('index.html not found');
|
||||
}
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// 🛡️ 安全中间件
|
||||
this.app.use(helmet({
|
||||
contentSecurityPolicy: false, // 允许内联样式和脚本
|
||||
@@ -121,6 +139,14 @@ class Application {
|
||||
this.app.set('trust proxy', 1);
|
||||
}
|
||||
|
||||
// 调试中间件 - 拦截所有 /admin-next 请求
|
||||
this.app.use((req, res, next) => {
|
||||
if (req.path.startsWith('/admin-next')) {
|
||||
logger.info(`🔍 DEBUG: Incoming request - method: ${req.method}, path: ${req.path}, originalUrl: ${req.originalUrl}`);
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// 🎨 新版管理界面静态文件服务(必须在其他路由之前)
|
||||
const adminSpaPath = path.join(__dirname, '..', 'web', 'admin-spa', 'dist');
|
||||
if (fs.existsSync(adminSpaPath)) {
|
||||
@@ -129,40 +155,54 @@ class Application {
|
||||
res.redirect(301, '/admin-next/');
|
||||
});
|
||||
|
||||
// 安全的静态文件服务配置
|
||||
this.app.use('/admin-next/', express.static(adminSpaPath, {
|
||||
maxAge: '1d', // 缓存静态资源1天
|
||||
etag: true,
|
||||
lastModified: true,
|
||||
index: 'index.html',
|
||||
// 安全选项:禁止目录遍历
|
||||
dotfiles: 'deny', // 拒绝访问点文件
|
||||
redirect: false, // 禁止目录重定向
|
||||
// 自定义错误处理
|
||||
setHeaders: (res, path) => {
|
||||
// 为不同类型的文件设置适当的缓存策略
|
||||
if (path.endsWith('.js') || path.endsWith('.css')) {
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); // 1年缓存
|
||||
} else if (path.endsWith('.html')) {
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
}
|
||||
// 使用 all 方法确保捕获所有 HTTP 方法
|
||||
this.app.all('/admin-next/', (req, res) => {
|
||||
logger.info('🎯 HIT: /admin-next/ route handler triggered!');
|
||||
logger.info(`Method: ${req.method}, Path: ${req.path}, URL: ${req.url}`);
|
||||
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
return res.status(405).send('Method Not Allowed');
|
||||
}
|
||||
}));
|
||||
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
res.sendFile(path.join(adminSpaPath, 'index.html'));
|
||||
});
|
||||
|
||||
// 处理SPA路由:所有未匹配的admin-next路径都返回index.html
|
||||
this.app.get('/admin-next/*', (req, res, next) => {
|
||||
// 安全检查:防止路径遍历攻击
|
||||
// 处理所有其他 /admin-next/* 路径(但排除根路径)
|
||||
this.app.get('/admin-next/*', (req, res) => {
|
||||
// 如果是根路径,跳过(应该由上面的路由处理)
|
||||
if (req.path === '/admin-next/') {
|
||||
logger.error('❌ ERROR: /admin-next/ should not reach here!');
|
||||
return res.status(500).send('Route configuration error');
|
||||
}
|
||||
|
||||
const requestPath = req.path.replace('/admin-next/', '');
|
||||
|
||||
// 安全检查
|
||||
if (requestPath.includes('..') || requestPath.includes('//') || requestPath.includes('\\')) {
|
||||
return res.status(400).json({ error: 'Invalid path' });
|
||||
}
|
||||
|
||||
// 如果是静态资源请求但文件不存在,返回404
|
||||
// 检查是否为静态资源
|
||||
const filePath = path.join(adminSpaPath, requestPath);
|
||||
|
||||
// 如果文件存在且是静态资源
|
||||
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
||||
// 设置缓存头
|
||||
if (filePath.endsWith('.js') || filePath.endsWith('.css')) {
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
||||
} else if (filePath.endsWith('.html')) {
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
}
|
||||
return res.sendFile(filePath);
|
||||
}
|
||||
|
||||
// 如果是静态资源但文件不存在
|
||||
if (requestPath.match(/\.(js|css|png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf)$/i)) {
|
||||
return res.status(404).send('Not found');
|
||||
}
|
||||
|
||||
// 其他路径返回index.html(SPA路由处理)
|
||||
// 其他所有路径返回 index.html(SPA 路由)
|
||||
res.sendFile(path.join(adminSpaPath, 'index.html'));
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ const apiKeyService = require('../services/apiKeyService');
|
||||
const claudeAccountService = require('../services/claudeAccountService');
|
||||
const claudeConsoleAccountService = require('../services/claudeConsoleAccountService');
|
||||
const geminiAccountService = require('../services/geminiAccountService');
|
||||
const accountGroupService = require('../services/accountGroupService');
|
||||
const redis = require('../models/redis');
|
||||
const { authenticateAdmin } = require('../middleware/auth');
|
||||
const logger = require('../utils/logger');
|
||||
@@ -712,6 +713,118 @@ router.delete('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 👥 账户分组管理
|
||||
|
||||
// 创建账户分组
|
||||
router.post('/account-groups', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { name, platform, description } = req.body;
|
||||
|
||||
const group = await accountGroupService.createGroup({
|
||||
name,
|
||||
platform,
|
||||
description
|
||||
});
|
||||
|
||||
res.json({ success: true, data: group });
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to create account group:', error);
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 获取所有分组
|
||||
router.get('/account-groups', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { platform } = req.query;
|
||||
const groups = await accountGroupService.getAllGroups(platform);
|
||||
res.json({ success: true, data: groups });
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get account groups:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 获取分组详情
|
||||
router.get('/account-groups/:groupId', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { groupId } = req.params;
|
||||
const group = await accountGroupService.getGroup(groupId);
|
||||
|
||||
if (!group) {
|
||||
return res.status(404).json({ error: '分组不存在' });
|
||||
}
|
||||
|
||||
res.json({ success: true, data: group });
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get account group:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 更新分组
|
||||
router.put('/account-groups/:groupId', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { groupId } = req.params;
|
||||
const updates = req.body;
|
||||
|
||||
const updatedGroup = await accountGroupService.updateGroup(groupId, updates);
|
||||
res.json({ success: true, data: updatedGroup });
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to update account group:', error);
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 删除分组
|
||||
router.delete('/account-groups/:groupId', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { groupId } = req.params;
|
||||
await accountGroupService.deleteGroup(groupId);
|
||||
res.json({ success: true, message: '分组删除成功' });
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to delete account group:', error);
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 获取分组成员
|
||||
router.get('/account-groups/:groupId/members', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { groupId } = req.params;
|
||||
const memberIds = await accountGroupService.getGroupMembers(groupId);
|
||||
|
||||
// 获取成员详细信息
|
||||
const members = [];
|
||||
for (const memberId of memberIds) {
|
||||
// 尝试从不同的服务获取账户信息
|
||||
let account = null;
|
||||
|
||||
// 先尝试Claude OAuth账户
|
||||
account = await claudeAccountService.getAccount(memberId);
|
||||
|
||||
// 如果找不到,尝试Claude Console账户
|
||||
if (!account) {
|
||||
account = await claudeConsoleAccountService.getAccount(memberId);
|
||||
}
|
||||
|
||||
// 如果还找不到,尝试Gemini账户
|
||||
if (!account) {
|
||||
account = await geminiAccountService.getAccount(memberId);
|
||||
}
|
||||
|
||||
if (account) {
|
||||
members.push(account);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true, data: members });
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get group members:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 🏢 Claude 账户管理
|
||||
|
||||
// 生成OAuth授权URL
|
||||
@@ -863,7 +976,8 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
|
||||
claudeAiOauth,
|
||||
proxy,
|
||||
accountType,
|
||||
priority
|
||||
priority,
|
||||
groupId
|
||||
} = req.body;
|
||||
|
||||
if (!name) {
|
||||
@@ -871,8 +985,13 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
|
||||
}
|
||||
|
||||
// 验证accountType的有效性
|
||||
if (accountType && !['shared', 'dedicated'].includes(accountType)) {
|
||||
return res.status(400).json({ error: 'Invalid account type. Must be "shared" or "dedicated"' });
|
||||
if (accountType && !['shared', 'dedicated', 'group'].includes(accountType)) {
|
||||
return res.status(400).json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' });
|
||||
}
|
||||
|
||||
// 如果是分组类型,验证groupId
|
||||
if (accountType === 'group' && !groupId) {
|
||||
return res.status(400).json({ error: 'Group ID is required for group type accounts' });
|
||||
}
|
||||
|
||||
// 验证priority的有效性
|
||||
@@ -892,6 +1011,11 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
|
||||
priority: priority || 50 // 默认优先级为50
|
||||
});
|
||||
|
||||
// 如果是分组类型,将账户添加到分组
|
||||
if (accountType === 'group' && groupId) {
|
||||
await accountGroupService.addAccountToGroup(newAccount.id, groupId, newAccount.platform);
|
||||
}
|
||||
|
||||
logger.success(`🏢 Admin created new Claude account: ${name} (${accountType || 'shared'})`);
|
||||
res.json({ success: true, data: newAccount });
|
||||
} catch (error) {
|
||||
@@ -911,6 +1035,39 @@ router.put('/claude-accounts/:accountId', authenticateAdmin, async (req, res) =>
|
||||
return res.status(400).json({ error: 'Priority must be a number between 1 and 100' });
|
||||
}
|
||||
|
||||
// 验证accountType的有效性
|
||||
if (updates.accountType && !['shared', 'dedicated', 'group'].includes(updates.accountType)) {
|
||||
return res.status(400).json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' });
|
||||
}
|
||||
|
||||
// 如果更新为分组类型,验证groupId
|
||||
if (updates.accountType === 'group' && !updates.groupId) {
|
||||
return res.status(400).json({ error: 'Group ID is required for group type accounts' });
|
||||
}
|
||||
|
||||
// 获取账户当前信息以处理分组变更
|
||||
const currentAccount = await claudeAccountService.getAccount(accountId);
|
||||
if (!currentAccount) {
|
||||
return res.status(404).json({ error: 'Account not found' });
|
||||
}
|
||||
|
||||
// 处理分组的变更
|
||||
if (updates.accountType !== undefined) {
|
||||
// 如果之前是分组类型,需要从原分组中移除
|
||||
if (currentAccount.accountType === 'group') {
|
||||
const oldGroup = await accountGroupService.getAccountGroup(accountId);
|
||||
if (oldGroup) {
|
||||
await accountGroupService.removeAccountFromGroup(accountId, oldGroup.id);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果新类型是分组,添加到新分组
|
||||
if (updates.accountType === 'group' && updates.groupId) {
|
||||
// 从路由知道这是 Claude OAuth 账户,平台为 'claude'
|
||||
await accountGroupService.addAccountToGroup(accountId, updates.groupId, 'claude');
|
||||
}
|
||||
}
|
||||
|
||||
await claudeAccountService.updateAccount(accountId, updates);
|
||||
|
||||
logger.success(`📝 Admin updated Claude account: ${accountId}`);
|
||||
@@ -926,6 +1083,15 @@ router.delete('/claude-accounts/:accountId', authenticateAdmin, async (req, res)
|
||||
try {
|
||||
const { accountId } = req.params;
|
||||
|
||||
// 获取账户信息以检查是否在分组中
|
||||
const account = await claudeAccountService.getAccount(accountId);
|
||||
if (account && account.accountType === 'group') {
|
||||
const group = await accountGroupService.getAccountGroup(accountId);
|
||||
if (group) {
|
||||
await accountGroupService.removeAccountFromGroup(accountId, group.id);
|
||||
}
|
||||
}
|
||||
|
||||
await claudeAccountService.deleteAccount(accountId);
|
||||
|
||||
logger.success(`🗑️ Admin deleted Claude account: ${accountId}`);
|
||||
@@ -1026,7 +1192,8 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => {
|
||||
userAgent,
|
||||
rateLimitDuration,
|
||||
proxy,
|
||||
accountType
|
||||
accountType,
|
||||
groupId
|
||||
} = req.body;
|
||||
|
||||
if (!name || !apiUrl || !apiKey) {
|
||||
@@ -1039,8 +1206,13 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => {
|
||||
}
|
||||
|
||||
// 验证accountType的有效性
|
||||
if (accountType && !['shared', 'dedicated'].includes(accountType)) {
|
||||
return res.status(400).json({ error: 'Invalid account type. Must be "shared" or "dedicated"' });
|
||||
if (accountType && !['shared', 'dedicated', 'group'].includes(accountType)) {
|
||||
return res.status(400).json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' });
|
||||
}
|
||||
|
||||
// 如果是分组类型,验证groupId
|
||||
if (accountType === 'group' && !groupId) {
|
||||
return res.status(400).json({ error: 'Group ID is required for group type accounts' });
|
||||
}
|
||||
|
||||
const newAccount = await claudeConsoleAccountService.createAccount({
|
||||
@@ -1056,6 +1228,11 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => {
|
||||
accountType: accountType || 'shared'
|
||||
});
|
||||
|
||||
// 如果是分组类型,将账户添加到分组
|
||||
if (accountType === 'group' && groupId) {
|
||||
await accountGroupService.addAccountToGroup(newAccount.id, groupId, 'claude');
|
||||
}
|
||||
|
||||
logger.success(`🎮 Admin created Claude Console account: ${name}`);
|
||||
res.json({ success: true, data: newAccount });
|
||||
} catch (error) {
|
||||
@@ -1263,8 +1440,23 @@ router.post('/gemini-accounts', authenticateAdmin, async (req, res) => {
|
||||
return res.status(400).json({ error: 'Account name is required' });
|
||||
}
|
||||
|
||||
// 验证accountType的有效性
|
||||
if (accountData.accountType && !['shared', 'dedicated', 'group'].includes(accountData.accountType)) {
|
||||
return res.status(400).json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' });
|
||||
}
|
||||
|
||||
// 如果是分组类型,验证groupId
|
||||
if (accountData.accountType === 'group' && !accountData.groupId) {
|
||||
return res.status(400).json({ error: 'Group ID is required for group type accounts' });
|
||||
}
|
||||
|
||||
const newAccount = await geminiAccountService.createAccount(accountData);
|
||||
|
||||
// 如果是分组类型,将账户添加到分组
|
||||
if (accountData.accountType === 'group' && accountData.groupId) {
|
||||
await accountGroupService.addAccountToGroup(newAccount.id, accountData.groupId, 'gemini');
|
||||
}
|
||||
|
||||
logger.success(`🏢 Admin created new Gemini account: ${accountData.name}`);
|
||||
res.json({ success: true, data: newAccount });
|
||||
} catch (error) {
|
||||
|
||||
351
src/services/accountGroupService.js
Normal file
351
src/services/accountGroupService.js
Normal file
@@ -0,0 +1,351 @@
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const logger = require('../utils/logger');
|
||||
const redis = require('../models/redis');
|
||||
|
||||
class AccountGroupService {
|
||||
constructor() {
|
||||
this.GROUPS_KEY = 'account_groups';
|
||||
this.GROUP_PREFIX = 'account_group:';
|
||||
this.GROUP_MEMBERS_PREFIX = 'account_group_members:';
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建账户分组
|
||||
* @param {Object} groupData - 分组数据
|
||||
* @param {string} groupData.name - 分组名称
|
||||
* @param {string} groupData.platform - 平台类型 (claude/gemini)
|
||||
* @param {string} groupData.description - 分组描述
|
||||
* @returns {Object} 创建的分组
|
||||
*/
|
||||
async createGroup(groupData) {
|
||||
try {
|
||||
const { name, platform, description = '' } = groupData;
|
||||
|
||||
// 验证必填字段
|
||||
if (!name || !platform) {
|
||||
throw new Error('分组名称和平台类型为必填项');
|
||||
}
|
||||
|
||||
// 验证平台类型
|
||||
if (!['claude', 'gemini'].includes(platform)) {
|
||||
throw new Error('平台类型必须是 claude 或 gemini');
|
||||
}
|
||||
|
||||
const client = redis.getClientSafe();
|
||||
const groupId = uuidv4();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const group = {
|
||||
id: groupId,
|
||||
name,
|
||||
platform,
|
||||
description,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
};
|
||||
|
||||
// 保存分组数据
|
||||
await client.hmset(`${this.GROUP_PREFIX}${groupId}`, group);
|
||||
|
||||
// 添加到分组集合
|
||||
await client.sadd(this.GROUPS_KEY, groupId);
|
||||
|
||||
logger.success(`✅ 创建账户分组成功: ${name} (${platform})`);
|
||||
|
||||
return group;
|
||||
} catch (error) {
|
||||
logger.error('❌ 创建账户分组失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新分组信息
|
||||
* @param {string} groupId - 分组ID
|
||||
* @param {Object} updates - 更新的字段
|
||||
* @returns {Object} 更新后的分组
|
||||
*/
|
||||
async updateGroup(groupId, updates) {
|
||||
try {
|
||||
const client = redis.getClientSafe();
|
||||
const groupKey = `${this.GROUP_PREFIX}${groupId}`;
|
||||
|
||||
// 检查分组是否存在
|
||||
const exists = await client.exists(groupKey);
|
||||
if (!exists) {
|
||||
throw new Error('分组不存在');
|
||||
}
|
||||
|
||||
// 获取现有分组数据
|
||||
const existingGroup = await client.hgetall(groupKey);
|
||||
|
||||
// 不允许修改平台类型
|
||||
if (updates.platform && updates.platform !== existingGroup.platform) {
|
||||
throw new Error('不能修改分组的平台类型');
|
||||
}
|
||||
|
||||
// 准备更新数据
|
||||
const updateData = {
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
// 移除不允许修改的字段
|
||||
delete updateData.id;
|
||||
delete updateData.platform;
|
||||
delete updateData.createdAt;
|
||||
|
||||
// 更新分组
|
||||
await client.hmset(groupKey, updateData);
|
||||
|
||||
// 返回更新后的完整数据
|
||||
const updatedGroup = await client.hgetall(groupKey);
|
||||
|
||||
logger.success(`✅ 更新账户分组成功: ${updatedGroup.name}`);
|
||||
|
||||
return updatedGroup;
|
||||
} catch (error) {
|
||||
logger.error('❌ 更新账户分组失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除分组
|
||||
* @param {string} groupId - 分组ID
|
||||
*/
|
||||
async deleteGroup(groupId) {
|
||||
try {
|
||||
const client = redis.getClientSafe();
|
||||
|
||||
// 检查分组是否存在
|
||||
const group = await this.getGroup(groupId);
|
||||
if (!group) {
|
||||
throw new Error('分组不存在');
|
||||
}
|
||||
|
||||
// 检查分组是否为空
|
||||
const members = await this.getGroupMembers(groupId);
|
||||
if (members.length > 0) {
|
||||
throw new Error('分组内还有账户,无法删除');
|
||||
}
|
||||
|
||||
// 检查是否有API Key绑定此分组
|
||||
const boundApiKeys = await this.getApiKeysUsingGroup(groupId);
|
||||
if (boundApiKeys.length > 0) {
|
||||
throw new Error('还有API Key使用此分组,无法删除');
|
||||
}
|
||||
|
||||
// 删除分组数据
|
||||
await client.del(`${this.GROUP_PREFIX}${groupId}`);
|
||||
await client.del(`${this.GROUP_MEMBERS_PREFIX}${groupId}`);
|
||||
|
||||
// 从分组集合中移除
|
||||
await client.srem(this.GROUPS_KEY, groupId);
|
||||
|
||||
logger.success(`✅ 删除账户分组成功: ${group.name}`);
|
||||
} catch (error) {
|
||||
logger.error('❌ 删除账户分组失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分组详情
|
||||
* @param {string} groupId - 分组ID
|
||||
* @returns {Object|null} 分组信息
|
||||
*/
|
||||
async getGroup(groupId) {
|
||||
try {
|
||||
const client = redis.getClientSafe();
|
||||
const groupData = await client.hgetall(`${this.GROUP_PREFIX}${groupId}`);
|
||||
|
||||
if (!groupData || Object.keys(groupData).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 获取成员数量
|
||||
const memberCount = await client.scard(`${this.GROUP_MEMBERS_PREFIX}${groupId}`);
|
||||
|
||||
return {
|
||||
...groupData,
|
||||
memberCount: memberCount || 0
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('❌ 获取分组详情失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有分组
|
||||
* @param {string} platform - 平台筛选 (可选)
|
||||
* @returns {Array} 分组列表
|
||||
*/
|
||||
async getAllGroups(platform = null) {
|
||||
try {
|
||||
const client = redis.getClientSafe();
|
||||
const groupIds = await client.smembers(this.GROUPS_KEY);
|
||||
|
||||
const groups = [];
|
||||
for (const groupId of groupIds) {
|
||||
const group = await this.getGroup(groupId);
|
||||
if (group) {
|
||||
// 如果指定了平台,进行筛选
|
||||
if (!platform || group.platform === platform) {
|
||||
groups.push(group);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 按创建时间倒序排序
|
||||
groups.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
||||
|
||||
return groups;
|
||||
} catch (error) {
|
||||
logger.error('❌ 获取分组列表失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加账户到分组
|
||||
* @param {string} accountId - 账户ID
|
||||
* @param {string} groupId - 分组ID
|
||||
* @param {string} accountPlatform - 账户平台
|
||||
*/
|
||||
async addAccountToGroup(accountId, groupId, accountPlatform) {
|
||||
try {
|
||||
const client = redis.getClientSafe();
|
||||
|
||||
// 获取分组信息
|
||||
const group = await this.getGroup(groupId);
|
||||
if (!group) {
|
||||
throw new Error('分组不存在');
|
||||
}
|
||||
|
||||
// 验证平台一致性 (Claude和Claude Console视为同一平台)
|
||||
const normalizedAccountPlatform = accountPlatform === 'claude-console' ? 'claude' : accountPlatform;
|
||||
if (normalizedAccountPlatform !== group.platform) {
|
||||
throw new Error('账户平台与分组平台不匹配');
|
||||
}
|
||||
|
||||
// 添加到分组成员集合
|
||||
await client.sadd(`${this.GROUP_MEMBERS_PREFIX}${groupId}`, accountId);
|
||||
|
||||
logger.success(`✅ 添加账户到分组成功: ${accountId} -> ${group.name}`);
|
||||
} catch (error) {
|
||||
logger.error('❌ 添加账户到分组失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从分组移除账户
|
||||
* @param {string} accountId - 账户ID
|
||||
* @param {string} groupId - 分组ID
|
||||
*/
|
||||
async removeAccountFromGroup(accountId, groupId) {
|
||||
try {
|
||||
const client = redis.getClientSafe();
|
||||
|
||||
// 从分组成员集合中移除
|
||||
await client.srem(`${this.GROUP_MEMBERS_PREFIX}${groupId}`, accountId);
|
||||
|
||||
logger.success(`✅ 从分组移除账户成功: ${accountId}`);
|
||||
} catch (error) {
|
||||
logger.error('❌ 从分组移除账户失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分组成员
|
||||
* @param {string} groupId - 分组ID
|
||||
* @returns {Array} 成员ID列表
|
||||
*/
|
||||
async getGroupMembers(groupId) {
|
||||
try {
|
||||
const client = redis.getClientSafe();
|
||||
const members = await client.smembers(`${this.GROUP_MEMBERS_PREFIX}${groupId}`);
|
||||
return members || [];
|
||||
} catch (error) {
|
||||
logger.error('❌ 获取分组成员失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查分组是否为空
|
||||
* @param {string} groupId - 分组ID
|
||||
* @returns {boolean} 是否为空
|
||||
*/
|
||||
async isGroupEmpty(groupId) {
|
||||
try {
|
||||
const members = await this.getGroupMembers(groupId);
|
||||
return members.length === 0;
|
||||
} catch (error) {
|
||||
logger.error('❌ 检查分组是否为空失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取使用指定分组的API Key列表
|
||||
* @param {string} groupId - 分组ID
|
||||
* @returns {Array} API Key列表
|
||||
*/
|
||||
async getApiKeysUsingGroup(groupId) {
|
||||
try {
|
||||
const client = redis.getClientSafe();
|
||||
const groupKey = `group:${groupId}`;
|
||||
|
||||
// 获取所有API Key
|
||||
const apiKeyIds = await client.smembers('api_keys');
|
||||
const boundApiKeys = [];
|
||||
|
||||
for (const keyId of apiKeyIds) {
|
||||
const keyData = await client.hgetall(`api_key:${keyId}`);
|
||||
if (keyData &&
|
||||
(keyData.claudeAccountId === groupKey ||
|
||||
keyData.geminiAccountId === groupKey)) {
|
||||
boundApiKeys.push({
|
||||
id: keyId,
|
||||
name: keyData.name
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return boundApiKeys;
|
||||
} catch (error) {
|
||||
logger.error('❌ 获取使用分组的API Key失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据账户ID获取其所属的分组
|
||||
* @param {string} accountId - 账户ID
|
||||
* @returns {Object|null} 分组信息
|
||||
*/
|
||||
async getAccountGroup(accountId) {
|
||||
try {
|
||||
const client = redis.getClientSafe();
|
||||
const allGroupIds = await client.smembers(this.GROUPS_KEY);
|
||||
|
||||
for (const groupId of allGroupIds) {
|
||||
const isMember = await client.sismember(`${this.GROUP_MEMBERS_PREFIX}${groupId}`, accountId);
|
||||
if (isMember) {
|
||||
return await this.getGroup(groupId);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
logger.error('❌ 获取账户所属分组失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new AccountGroupService();
|
||||
@@ -68,7 +68,7 @@ class ClaudeAccountService {
|
||||
lastRefreshAt: '',
|
||||
status: 'active', // 有OAuth数据的账户直接设为active
|
||||
errorMessage: '',
|
||||
schedulable: schedulable.toString() // 是否可被调度
|
||||
schedulable: schedulable.toString(), // 是否可被调度
|
||||
};
|
||||
} else {
|
||||
// 兼容旧格式
|
||||
@@ -91,7 +91,7 @@ class ClaudeAccountService {
|
||||
lastRefreshAt: '',
|
||||
status: 'created', // created, active, expired, error
|
||||
errorMessage: '',
|
||||
schedulable: schedulable.toString() // 是否可被调度
|
||||
schedulable: schedulable.toString(), // 是否可被调度
|
||||
};
|
||||
}
|
||||
|
||||
@@ -233,6 +233,23 @@ class ClaudeAccountService {
|
||||
}
|
||||
}
|
||||
|
||||
// 🔍 获取账户信息
|
||||
async getAccount(accountId) {
|
||||
try {
|
||||
const accountData = await redis.getClaudeAccount(accountId);
|
||||
|
||||
if (!accountData || Object.keys(accountData).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
return accountData;
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get Claude account:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 🎯 获取有效的访问token
|
||||
async getValidAccessToken(accountId) {
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const claudeAccountService = require('./claudeAccountService');
|
||||
const claudeConsoleAccountService = require('./claudeConsoleAccountService');
|
||||
const accountGroupService = require('./accountGroupService');
|
||||
const redis = require('../models/redis');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
@@ -11,9 +12,16 @@ class UnifiedClaudeScheduler {
|
||||
// 🎯 统一调度Claude账号(官方和Console)
|
||||
async selectAccountForApiKey(apiKeyData, sessionHash = null, requestedModel = null) {
|
||||
try {
|
||||
// 如果API Key绑定了专属账户,优先使用
|
||||
// 1. 检查Claude OAuth账户绑定
|
||||
// 如果API Key绑定了专属账户或分组,优先使用
|
||||
if (apiKeyData.claudeAccountId) {
|
||||
// 检查是否是分组
|
||||
if (apiKeyData.claudeAccountId.startsWith('group:')) {
|
||||
const groupId = apiKeyData.claudeAccountId.replace('group:', '');
|
||||
logger.info(`🎯 API key ${apiKeyData.name} is bound to group ${groupId}, selecting from group`);
|
||||
return await this.selectAccountFromGroup(groupId, sessionHash, requestedModel, apiKeyData);
|
||||
}
|
||||
|
||||
// 普通专属账户
|
||||
const boundAccount = await redis.getClaudeAccount(apiKeyData.claudeAccountId);
|
||||
if (boundAccount && boundAccount.isActive === 'true' && boundAccount.status !== 'error') {
|
||||
logger.info(`🎯 Using bound dedicated Claude OAuth account: ${boundAccount.name} (${apiKeyData.claudeAccountId}) for API key ${apiKeyData.name}`);
|
||||
@@ -360,6 +368,132 @@ class UnifiedClaudeScheduler {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 👥 从分组中选择账户
|
||||
async selectAccountFromGroup(groupId, sessionHash = null, requestedModel = null, apiKeyData = null) {
|
||||
try {
|
||||
// 获取分组信息
|
||||
const group = await accountGroupService.getGroup(groupId);
|
||||
if (!group) {
|
||||
throw new Error(`Group ${groupId} not found`);
|
||||
}
|
||||
|
||||
logger.info(`👥 Selecting account from group: ${group.name} (${group.platform})`);
|
||||
|
||||
// 如果有会话哈希,检查是否有已映射的账户
|
||||
if (sessionHash) {
|
||||
const mappedAccount = await this._getSessionMapping(sessionHash);
|
||||
if (mappedAccount) {
|
||||
// 验证映射的账户是否属于这个分组
|
||||
const memberIds = await accountGroupService.getGroupMembers(groupId);
|
||||
if (memberIds.includes(mappedAccount.accountId)) {
|
||||
const isAvailable = await this._isAccountAvailable(mappedAccount.accountId, mappedAccount.accountType);
|
||||
if (isAvailable) {
|
||||
logger.info(`🎯 Using sticky session account from group: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`);
|
||||
return mappedAccount;
|
||||
}
|
||||
}
|
||||
// 如果映射的账户不可用或不在分组中,删除映射
|
||||
await this._deleteSessionMapping(sessionHash);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取分组内的所有账户
|
||||
const memberIds = await accountGroupService.getGroupMembers(groupId);
|
||||
if (memberIds.length === 0) {
|
||||
throw new Error(`Group ${group.name} has no members`);
|
||||
}
|
||||
|
||||
const availableAccounts = [];
|
||||
|
||||
// 获取所有成员账户的详细信息
|
||||
for (const memberId of memberIds) {
|
||||
let account = null;
|
||||
let accountType = null;
|
||||
|
||||
// 根据平台类型获取账户
|
||||
if (group.platform === 'claude') {
|
||||
// 先尝试官方账户
|
||||
account = await redis.getClaudeAccount(memberId);
|
||||
if (account) {
|
||||
accountType = 'claude-official';
|
||||
} else {
|
||||
// 尝试Console账户
|
||||
account = await claudeConsoleAccountService.getAccount(memberId);
|
||||
if (account) {
|
||||
accountType = 'claude-console';
|
||||
}
|
||||
}
|
||||
} else if (group.platform === 'gemini') {
|
||||
// Gemini暂时不支持,预留接口
|
||||
logger.warn(`⚠️ Gemini group scheduling not yet implemented`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!account) {
|
||||
logger.warn(`⚠️ Account ${memberId} not found in group ${group.name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查账户是否可用
|
||||
const isActive = accountType === 'claude-official'
|
||||
? account.isActive === 'true'
|
||||
: account.isActive === true;
|
||||
|
||||
const status = accountType === 'claude-official'
|
||||
? account.status !== 'error' && account.status !== 'blocked'
|
||||
: account.status === 'active';
|
||||
|
||||
if (isActive && status && account.schedulable !== false) {
|
||||
// 检查模型支持(Console账户)
|
||||
if (accountType === 'claude-console' && requestedModel && account.supportedModels && account.supportedModels.length > 0) {
|
||||
if (!account.supportedModels.includes(requestedModel)) {
|
||||
logger.info(`🚫 Account ${account.name} in group does not support model ${requestedModel}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否被限流
|
||||
const isRateLimited = await this.isAccountRateLimited(account.id, accountType);
|
||||
if (!isRateLimited) {
|
||||
availableAccounts.push({
|
||||
...account,
|
||||
accountId: account.id,
|
||||
accountType: accountType,
|
||||
priority: parseInt(account.priority) || 50,
|
||||
lastUsedAt: account.lastUsedAt || '0'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (availableAccounts.length === 0) {
|
||||
throw new Error(`No available accounts in group ${group.name}`);
|
||||
}
|
||||
|
||||
// 使用现有的优先级排序逻辑
|
||||
const sortedAccounts = this._sortAccountsByPriority(availableAccounts);
|
||||
|
||||
// 选择第一个账户
|
||||
const selectedAccount = sortedAccounts[0];
|
||||
|
||||
// 如果有会话哈希,建立新的映射
|
||||
if (sessionHash) {
|
||||
await this._setSessionMapping(sessionHash, selectedAccount.accountId, selectedAccount.accountType);
|
||||
logger.info(`🎯 Created new sticky session mapping in group: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) for session ${sessionHash}`);
|
||||
}
|
||||
|
||||
logger.info(`🎯 Selected account from group ${group.name}: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) with priority ${selectedAccount.priority}`);
|
||||
|
||||
return {
|
||||
accountId: selectedAccount.accountId,
|
||||
accountType: selectedAccount.accountType
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to select account from group ${groupId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new UnifiedClaudeScheduler();
|
||||
@@ -159,12 +159,50 @@
|
||||
>
|
||||
<span class="text-sm text-gray-700">专属账户</span>
|
||||
</label>
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input
|
||||
v-model="form.accountType"
|
||||
type="radio"
|
||||
value="group"
|
||||
class="mr-2"
|
||||
>
|
||||
<span class="text-sm text-gray-700">分组调度</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-2">
|
||||
共享账户:供所有API Key使用;专属账户:仅供特定API Key使用
|
||||
共享账户:供所有API Key使用;专属账户:仅供特定API Key使用;分组调度:加入分组供分组内调度
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 分组选择器 -->
|
||||
<div v-if="form.accountType === 'group'">
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">选择分组 *</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
|
||||
type="button"
|
||||
class="px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
@click="refreshGroups"
|
||||
>
|
||||
<i class="fas fa-sync-alt" :class="{ 'animate-spin': loadingGroups }" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gemini 项目编号字段 -->
|
||||
<div v-if="form.platform === 'gemini'">
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">项目编号 (可选)</label>
|
||||
@@ -555,12 +593,50 @@
|
||||
>
|
||||
<span class="text-sm text-gray-700">专属账户</span>
|
||||
</label>
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input
|
||||
v-model="form.accountType"
|
||||
type="radio"
|
||||
value="group"
|
||||
class="mr-2"
|
||||
>
|
||||
<span class="text-sm text-gray-700">分组调度</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-2">
|
||||
共享账户:供所有API Key使用;专属账户:仅供特定API Key使用
|
||||
共享账户:供所有API Key使用;专属账户:仅供特定API Key使用;分组调度:加入分组供分组内调度
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 分组选择器 -->
|
||||
<div v-if="form.accountType === 'group'">
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">选择分组 *</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
|
||||
type="button"
|
||||
class="px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
@click="refreshGroups"
|
||||
>
|
||||
<i class="fas fa-sync-alt" :class="{ 'animate-spin': loadingGroups }" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gemini 项目编号字段 -->
|
||||
<div v-if="form.platform === 'gemini'">
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">项目编号 (可选)</label>
|
||||
@@ -813,17 +889,26 @@
|
||||
@confirm="handleConfirm"
|
||||
@cancel="handleCancel"
|
||||
/>
|
||||
|
||||
<!-- 分组管理模态框 -->
|
||||
<GroupManagementModal
|
||||
v-if="showGroupManagement"
|
||||
@close="showGroupManagement = false"
|
||||
@refresh="handleGroupRefresh"
|
||||
/>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { apiClient } from '@/config/api'
|
||||
import { useAccountsStore } from '@/stores/accounts'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import ProxyConfig from './ProxyConfig.vue'
|
||||
import OAuthFlow from './OAuthFlow.vue'
|
||||
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
||||
import GroupManagementModal from './GroupManagementModal.vue'
|
||||
|
||||
const props = defineProps({
|
||||
account: {
|
||||
@@ -874,6 +959,7 @@ const form = ref({
|
||||
name: props.account?.name || '',
|
||||
description: props.account?.description || '',
|
||||
accountType: props.account?.accountType || 'shared',
|
||||
groupId: '',
|
||||
projectId: props.account?.projectId || '',
|
||||
accessToken: '',
|
||||
refreshToken: '',
|
||||
@@ -941,6 +1027,12 @@ const nextStep = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
// 分组类型验证
|
||||
if (form.value.accountType === 'group' && (!form.value.groupId || form.value.groupId.trim() === '')) {
|
||||
showToast('请选择一个分组', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
// 对于Gemini账户,检查项目编号
|
||||
if (form.value.platform === 'gemini' && oauthStep.value === 1 && form.value.addType === 'oauth') {
|
||||
if (!form.value.projectId || form.value.projectId.trim() === '') {
|
||||
@@ -968,6 +1060,7 @@ const handleOAuthSuccess = async (tokenInfo) => {
|
||||
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,
|
||||
@@ -1034,6 +1127,12 @@ const createAccount = async () => {
|
||||
hasError = true
|
||||
}
|
||||
|
||||
// 分组类型验证
|
||||
if (form.value.accountType === 'group' && (!form.value.groupId || form.value.groupId.trim() === '')) {
|
||||
showToast('请选择一个分组', 'error')
|
||||
hasError = true
|
||||
}
|
||||
|
||||
if (hasError) {
|
||||
return
|
||||
}
|
||||
@@ -1044,6 +1143,7 @@ const createAccount = async () => {
|
||||
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,
|
||||
@@ -1121,6 +1221,12 @@ const updateAccount = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
// 分组类型验证
|
||||
if (form.value.accountType === 'group' && (!form.value.groupId || form.value.groupId.trim() === '')) {
|
||||
showToast('请选择一个分组', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
// 对于Gemini账户,检查项目编号
|
||||
if (form.value.platform === 'gemini') {
|
||||
if (!form.value.projectId || form.value.projectId.trim() === '') {
|
||||
@@ -1143,6 +1249,7 @@ const updateAccount = async () => {
|
||||
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,
|
||||
@@ -1247,11 +1354,71 @@ watch(() => form.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') {
|
||||
form.value.addType = 'manual' // Claude Console 只支持手动模式
|
||||
}
|
||||
|
||||
// 平台变化时,清空分组选择
|
||||
if (form.value.accountType === 'group') {
|
||||
form.value.groupId = ''
|
||||
}
|
||||
})
|
||||
|
||||
// 监听账户类型变化
|
||||
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
|
||||
}
|
||||
})
|
||||
|
||||
// 添加模型映射
|
||||
@@ -1317,6 +1484,7 @@ watch(() => props.account, (newAccount) => {
|
||||
name: newAccount.name,
|
||||
description: newAccount.description || '',
|
||||
accountType: newAccount.accountType || 'shared',
|
||||
groupId: '',
|
||||
projectId: newAccount.projectId || '',
|
||||
accessToken: '',
|
||||
refreshToken: '',
|
||||
@@ -1328,6 +1496,22 @@ watch(() => props.account, (newAccount) => {
|
||||
userAgent: newAccount.userAgent || '',
|
||||
rateLimitDuration: newAccount.rateLimitDuration || 60
|
||||
}
|
||||
|
||||
// 如果是分组类型,加载分组ID
|
||||
if (newAccount.accountType === 'group') {
|
||||
// 先加载分组列表
|
||||
loadGroups().then(() => {
|
||||
// 查找账户所属的分组
|
||||
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 })
|
||||
|
||||
|
||||
418
web/admin-spa/src/components/accounts/GroupManagementModal.vue
Normal file
418
web/admin-spa/src/components/accounts/GroupManagementModal.vue
Normal file
@@ -0,0 +1,418 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 modal z-50 flex items-center justify-center p-3 sm:p-4"
|
||||
>
|
||||
<div class="modal-content w-full max-w-4xl p-4 sm:p-6 md:p-8 mx-auto max-h-[90vh] overflow-y-auto custom-scrollbar">
|
||||
<div class="flex items-center justify-between mb-4 sm:mb-6">
|
||||
<div class="flex items-center gap-2 sm:gap-3">
|
||||
<div class="w-8 h-8 sm:w-10 sm:h-10 bg-gradient-to-br from-purple-500 to-purple-600 rounded-lg sm:rounded-xl flex items-center justify-center">
|
||||
<i class="fas fa-layer-group text-white text-sm sm:text-base" />
|
||||
</div>
|
||||
<h3 class="text-lg sm:text-xl font-bold text-gray-900">
|
||||
账户分组管理
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
class="text-gray-400 hover:text-gray-600 transition-colors p-1"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<i class="fas fa-times text-lg sm:text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 添加分组按钮 -->
|
||||
<div class="mb-6">
|
||||
<button
|
||||
class="btn btn-primary px-4 py-2"
|
||||
@click="showCreateForm = true"
|
||||
>
|
||||
<i class="fas fa-plus mr-2" />
|
||||
创建新分组
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 创建分组表单 -->
|
||||
<div
|
||||
v-if="showCreateForm"
|
||||
class="mb-6 p-4 bg-blue-50 rounded-lg border border-blue-200"
|
||||
>
|
||||
<h4 class="text-lg font-semibold text-gray-900 mb-4">创建新分组</h4>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-2">分组名称 *</label>
|
||||
<input
|
||||
v-model="createForm.name"
|
||||
type="text"
|
||||
class="form-input w-full"
|
||||
placeholder="输入分组名称"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-2">平台类型 *</label>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input
|
||||
v-model="createForm.platform"
|
||||
type="radio"
|
||||
value="claude"
|
||||
class="mr-2"
|
||||
>
|
||||
<span class="text-sm text-gray-700">Claude</span>
|
||||
</label>
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input
|
||||
v-model="createForm.platform"
|
||||
type="radio"
|
||||
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-2">描述 (可选)</label>
|
||||
<textarea
|
||||
v-model="createForm.description"
|
||||
rows="2"
|
||||
class="form-input w-full resize-none"
|
||||
placeholder="分组描述..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
class="btn btn-primary px-4 py-2"
|
||||
:disabled="!createForm.name || !createForm.platform || creating"
|
||||
@click="createGroup"
|
||||
>
|
||||
<div
|
||||
v-if="creating"
|
||||
class="loading-spinner mr-2"
|
||||
/>
|
||||
{{ creating ? '创建中...' : '创建' }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-secondary px-4 py-2"
|
||||
@click="cancelCreate"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分组列表 -->
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-if="loading"
|
||||
class="text-center py-8"
|
||||
>
|
||||
<div class="loading-spinner-lg mx-auto mb-4" />
|
||||
<p class="text-gray-500">加载中...</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="groups.length === 0"
|
||||
class="text-center py-8 bg-gray-50 rounded-lg"
|
||||
>
|
||||
<i class="fas fa-layer-group text-4xl text-gray-300 mb-4" />
|
||||
<p class="text-gray-500">暂无分组</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="grid gap-4 grid-cols-1 md:grid-cols-2"
|
||||
>
|
||||
<div
|
||||
v-for="group in groups"
|
||||
:key="group.id"
|
||||
class="bg-white rounded-lg border p-4 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div class="flex-1">
|
||||
<h4 class="font-semibold text-gray-900">{{ group.name }}</h4>
|
||||
<p class="text-sm text-gray-500 mt-1">{{ group.description || '暂无描述' }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 ml-4">
|
||||
<span
|
||||
:class="[
|
||||
'px-2 py-1 text-xs font-medium rounded-full',
|
||||
group.platform === 'claude'
|
||||
? 'bg-purple-100 text-purple-700'
|
||||
: 'bg-blue-100 text-blue-700'
|
||||
]"
|
||||
>
|
||||
{{ group.platform === 'claude' ? 'Claude' : 'Gemini' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between text-sm text-gray-600">
|
||||
<div class="flex items-center gap-4">
|
||||
<span>
|
||||
<i class="fas fa-users mr-1" />
|
||||
{{ group.memberCount || 0 }} 个成员
|
||||
</span>
|
||||
<span>
|
||||
<i class="fas fa-clock mr-1" />
|
||||
{{ formatDate(group.createdAt) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="text-blue-600 hover:text-blue-800 transition-colors"
|
||||
title="编辑"
|
||||
@click="editGroup(group)"
|
||||
>
|
||||
<i class="fas fa-edit" />
|
||||
</button>
|
||||
<button
|
||||
class="text-red-600 hover:text-red-800 transition-colors"
|
||||
title="删除"
|
||||
:disabled="group.memberCount > 0"
|
||||
@click="deleteGroup(group)"
|
||||
>
|
||||
<i class="fas fa-trash" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑分组模态框 -->
|
||||
<div
|
||||
v-if="showEditForm"
|
||||
class="fixed inset-0 modal z-60 flex items-center justify-center p-3 sm:p-4"
|
||||
>
|
||||
<div class="modal-content w-full max-w-lg p-4 sm:p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-bold text-gray-900">编辑分组</h3>
|
||||
<button
|
||||
class="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
@click="cancelEdit"
|
||||
>
|
||||
<i class="fas fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-2">分组名称 *</label>
|
||||
<input
|
||||
v-model="editForm.name"
|
||||
type="text"
|
||||
class="form-input w-full"
|
||||
placeholder="输入分组名称"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-2">平台类型</label>
|
||||
<div class="px-3 py-2 bg-gray-100 rounded-lg text-sm text-gray-600">
|
||||
{{ editForm.platform === 'claude' ? 'Claude' : 'Gemini' }}
|
||||
<span class="text-xs text-gray-500 ml-2">(不可修改)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-2">描述 (可选)</label>
|
||||
<textarea
|
||||
v-model="editForm.description"
|
||||
rows="2"
|
||||
class="form-input w-full resize-none"
|
||||
placeholder="分组描述..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
class="btn btn-primary px-4 py-2 flex-1"
|
||||
:disabled="!editForm.name || updating"
|
||||
@click="updateGroup"
|
||||
>
|
||||
<div
|
||||
v-if="updating"
|
||||
class="loading-spinner mr-2"
|
||||
/>
|
||||
{{ updating ? '更新中...' : '更新' }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-secondary px-4 py-2 flex-1"
|
||||
@click="cancelEdit"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { apiClient } from '@/config/api'
|
||||
|
||||
const emit = defineEmits(['close', 'refresh'])
|
||||
|
||||
const show = ref(true)
|
||||
const loading = ref(false)
|
||||
const groups = ref([])
|
||||
|
||||
// 创建表单
|
||||
const showCreateForm = ref(false)
|
||||
const creating = ref(false)
|
||||
const createForm = ref({
|
||||
name: '',
|
||||
platform: 'claude',
|
||||
description: ''
|
||||
})
|
||||
|
||||
// 编辑表单
|
||||
const showEditForm = ref(false)
|
||||
const updating = ref(false)
|
||||
const editingGroup = ref(null)
|
||||
const editForm = ref({
|
||||
name: '',
|
||||
platform: '',
|
||||
description: ''
|
||||
})
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return '-'
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
// 加载分组列表
|
||||
const loadGroups = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await apiClient.get('/admin/account-groups')
|
||||
groups.value = response.data || []
|
||||
} catch (error) {
|
||||
showToast('加载分组列表失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 创建分组
|
||||
const createGroup = async () => {
|
||||
if (!createForm.value.name || !createForm.value.platform) {
|
||||
showToast('请填写必填项', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
creating.value = true
|
||||
try {
|
||||
await apiClient.post('/admin/account-groups', {
|
||||
name: createForm.value.name,
|
||||
platform: createForm.value.platform,
|
||||
description: createForm.value.description
|
||||
})
|
||||
|
||||
showToast('分组创建成功', 'success')
|
||||
cancelCreate()
|
||||
await loadGroups()
|
||||
emit('refresh')
|
||||
} catch (error) {
|
||||
showToast(error.response?.data?.error || '创建分组失败', 'error')
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 取消创建
|
||||
const cancelCreate = () => {
|
||||
showCreateForm.value = false
|
||||
createForm.value = {
|
||||
name: '',
|
||||
platform: 'claude',
|
||||
description: ''
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑分组
|
||||
const editGroup = (group) => {
|
||||
editingGroup.value = group
|
||||
editForm.value = {
|
||||
name: group.name,
|
||||
platform: group.platform,
|
||||
description: group.description || ''
|
||||
}
|
||||
showEditForm.value = true
|
||||
}
|
||||
|
||||
// 更新分组
|
||||
const updateGroup = async () => {
|
||||
if (!editForm.value.name) {
|
||||
showToast('请填写分组名称', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
updating.value = true
|
||||
try {
|
||||
await apiClient.put(`/admin/account-groups/${editingGroup.value.id}`, {
|
||||
name: editForm.value.name,
|
||||
description: editForm.value.description
|
||||
})
|
||||
|
||||
showToast('分组更新成功', 'success')
|
||||
cancelEdit()
|
||||
await loadGroups()
|
||||
emit('refresh')
|
||||
} catch (error) {
|
||||
showToast(error.response?.data?.error || '更新分组失败', 'error')
|
||||
} finally {
|
||||
updating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 取消编辑
|
||||
const cancelEdit = () => {
|
||||
showEditForm.value = false
|
||||
editingGroup.value = null
|
||||
editForm.value = {
|
||||
name: '',
|
||||
platform: '',
|
||||
description: ''
|
||||
}
|
||||
}
|
||||
|
||||
// 删除分组
|
||||
const deleteGroup = async (group) => {
|
||||
if (group.memberCount > 0) {
|
||||
showToast('分组内还有成员,无法删除', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
if (!confirm(`确定要删除分组 "${group.name}" 吗?`)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await apiClient.delete(`/admin/account-groups/${group.id}`)
|
||||
showToast('分组删除成功', 'success')
|
||||
await loadGroups()
|
||||
emit('refresh')
|
||||
} catch (error) {
|
||||
showToast(error.response?.data?.error || '删除分组失败', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载时加载数据
|
||||
onMounted(() => {
|
||||
loadGroups()
|
||||
})
|
||||
</script>
|
||||
@@ -433,6 +433,18 @@
|
||||
<option value="">
|
||||
使用共享账号池
|
||||
</option>
|
||||
<optgroup
|
||||
v-if="localAccounts.claudeGroups && localAccounts.claudeGroups.length > 0"
|
||||
label="调度分组"
|
||||
>
|
||||
<option
|
||||
v-for="group in localAccounts.claudeGroups"
|
||||
:key="`group:${group.id}`"
|
||||
:value="`group:${group.id}`"
|
||||
>
|
||||
{{ group.name }} ({{ group.memberCount || 0 }} 个成员)
|
||||
</option>
|
||||
</optgroup>
|
||||
<optgroup
|
||||
v-if="localAccounts.claude.filter(a => a.isDedicated && a.platform === 'claude-oauth').length > 0"
|
||||
label="Claude OAuth 账号"
|
||||
@@ -469,13 +481,30 @@
|
||||
<option value="">
|
||||
使用共享账号池
|
||||
</option>
|
||||
<option
|
||||
v-for="account in localAccounts.gemini.filter(a => a.isDedicated)"
|
||||
:key="account.id"
|
||||
:value="account.id"
|
||||
<optgroup
|
||||
v-if="localAccounts.geminiGroups && localAccounts.geminiGroups.length > 0"
|
||||
label="调度分组"
|
||||
>
|
||||
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
|
||||
</option>
|
||||
<option
|
||||
v-for="group in localAccounts.geminiGroups"
|
||||
:key="`group:${group.id}`"
|
||||
:value="`group:${group.id}`"
|
||||
>
|
||||
{{ group.name }} ({{ group.memberCount || 0 }} 个成员)
|
||||
</option>
|
||||
</optgroup>
|
||||
<optgroup
|
||||
v-if="localAccounts.gemini.filter(a => a.isDedicated).length > 0"
|
||||
label="Gemini 账号"
|
||||
>
|
||||
<option
|
||||
v-for="account in localAccounts.gemini.filter(a => a.isDedicated)"
|
||||
:key="account.id"
|
||||
:value="account.id"
|
||||
>
|
||||
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
|
||||
</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -650,7 +679,7 @@ const clientsStore = useClientsStore()
|
||||
const apiKeysStore = useApiKeysStore()
|
||||
const loading = ref(false)
|
||||
const accountsLoading = ref(false)
|
||||
const localAccounts = ref({ claude: [], gemini: [] })
|
||||
const localAccounts = ref({ claude: [], gemini: [], claudeGroups: [], geminiGroups: [] })
|
||||
|
||||
// 表单验证状态
|
||||
const errors = ref({
|
||||
@@ -702,7 +731,9 @@ onMounted(async () => {
|
||||
if (props.accounts) {
|
||||
localAccounts.value = {
|
||||
claude: props.accounts.claude || [],
|
||||
gemini: props.accounts.gemini || []
|
||||
gemini: props.accounts.gemini || [],
|
||||
claudeGroups: props.accounts.claudeGroups || [],
|
||||
geminiGroups: props.accounts.geminiGroups || []
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -711,10 +742,11 @@ onMounted(async () => {
|
||||
const refreshAccounts = async () => {
|
||||
accountsLoading.value = true
|
||||
try {
|
||||
const [claudeData, claudeConsoleData, geminiData] = await Promise.all([
|
||||
const [claudeData, claudeConsoleData, geminiData, groupsData] = await Promise.all([
|
||||
apiClient.get('/admin/claude-accounts'),
|
||||
apiClient.get('/admin/claude-console-accounts'),
|
||||
apiClient.get('/admin/gemini-accounts')
|
||||
apiClient.get('/admin/gemini-accounts'),
|
||||
apiClient.get('/admin/account-groups')
|
||||
])
|
||||
|
||||
// 合并Claude OAuth账户和Claude Console账户
|
||||
@@ -749,6 +781,13 @@ const refreshAccounts = async () => {
|
||||
}))
|
||||
}
|
||||
|
||||
// 处理分组数据
|
||||
if (groupsData.success) {
|
||||
const allGroups = groupsData.data || []
|
||||
localAccounts.value.claudeGroups = allGroups.filter(g => g.platform === 'claude')
|
||||
localAccounts.value.geminiGroups = allGroups.filter(g => g.platform === 'gemini')
|
||||
}
|
||||
|
||||
showToast('账号列表已刷新', 'success')
|
||||
} catch (error) {
|
||||
showToast('刷新账号列表失败', 'error')
|
||||
|
||||
@@ -302,6 +302,18 @@
|
||||
<option value="">
|
||||
使用共享账号池
|
||||
</option>
|
||||
<optgroup
|
||||
v-if="localAccounts.claudeGroups && localAccounts.claudeGroups.length > 0"
|
||||
label="调度分组"
|
||||
>
|
||||
<option
|
||||
v-for="group in localAccounts.claudeGroups"
|
||||
:key="`group:${group.id}`"
|
||||
:value="`group:${group.id}`"
|
||||
>
|
||||
{{ group.name }} ({{ group.memberCount || 0 }} 个成员)
|
||||
</option>
|
||||
</optgroup>
|
||||
<optgroup
|
||||
v-if="localAccounts.claude.filter(a => a.isDedicated && a.platform === 'claude-oauth').length > 0"
|
||||
label="Claude OAuth 账号"
|
||||
@@ -338,13 +350,30 @@
|
||||
<option value="">
|
||||
使用共享账号池
|
||||
</option>
|
||||
<option
|
||||
v-for="account in localAccounts.gemini.filter(a => a.isDedicated)"
|
||||
:key="account.id"
|
||||
:value="account.id"
|
||||
<optgroup
|
||||
v-if="localAccounts.geminiGroups && localAccounts.geminiGroups.length > 0"
|
||||
label="调度分组"
|
||||
>
|
||||
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
|
||||
</option>
|
||||
<option
|
||||
v-for="group in localAccounts.geminiGroups"
|
||||
:key="`group:${group.id}`"
|
||||
:value="`group:${group.id}`"
|
||||
>
|
||||
{{ group.name }} ({{ group.memberCount || 0 }} 个成员)
|
||||
</option>
|
||||
</optgroup>
|
||||
<optgroup
|
||||
v-if="localAccounts.gemini.filter(a => a.isDedicated).length > 0"
|
||||
label="Gemini 账号"
|
||||
>
|
||||
<option
|
||||
v-for="account in localAccounts.gemini.filter(a => a.isDedicated)"
|
||||
:key="account.id"
|
||||
:value="account.id"
|
||||
>
|
||||
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
|
||||
</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -526,7 +555,7 @@ const clientsStore = useClientsStore()
|
||||
const apiKeysStore = useApiKeysStore()
|
||||
const loading = ref(false)
|
||||
const accountsLoading = ref(false)
|
||||
const localAccounts = ref({ claude: [], gemini: [] })
|
||||
const localAccounts = ref({ claude: [], gemini: [], claudeGroups: [], geminiGroups: [] })
|
||||
|
||||
// 支持的客户端列表
|
||||
const supportedClients = ref([])
|
||||
@@ -656,10 +685,11 @@ const updateApiKey = async () => {
|
||||
const refreshAccounts = async () => {
|
||||
accountsLoading.value = true
|
||||
try {
|
||||
const [claudeData, claudeConsoleData, geminiData] = await Promise.all([
|
||||
const [claudeData, claudeConsoleData, geminiData, groupsData] = await Promise.all([
|
||||
apiClient.get('/admin/claude-accounts'),
|
||||
apiClient.get('/admin/claude-console-accounts'),
|
||||
apiClient.get('/admin/gemini-accounts')
|
||||
apiClient.get('/admin/gemini-accounts'),
|
||||
apiClient.get('/admin/account-groups')
|
||||
])
|
||||
|
||||
// 合并Claude OAuth账户和Claude Console账户
|
||||
@@ -694,6 +724,13 @@ const refreshAccounts = async () => {
|
||||
}))
|
||||
}
|
||||
|
||||
// 处理分组数据
|
||||
if (groupsData.success) {
|
||||
const allGroups = groupsData.data || []
|
||||
localAccounts.value.claudeGroups = allGroups.filter(g => g.platform === 'claude')
|
||||
localAccounts.value.geminiGroups = allGroups.filter(g => g.platform === 'gemini')
|
||||
}
|
||||
|
||||
showToast('账号列表已刷新', 'success')
|
||||
} catch (error) {
|
||||
showToast('刷新账号列表失败', 'error')
|
||||
@@ -712,7 +749,9 @@ onMounted(async () => {
|
||||
if (props.accounts) {
|
||||
localAccounts.value = {
|
||||
claude: props.accounts.claude || [],
|
||||
gemini: props.accounts.gemini || []
|
||||
gemini: props.accounts.gemini || [],
|
||||
claudeGroups: props.accounts.claudeGroups || [],
|
||||
geminiGroups: props.accounts.geminiGroups || []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,19 @@ const ApiStatsView = () => import('@/views/ApiStatsView.vue')
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/api-stats'
|
||||
redirect: () => {
|
||||
// 智能重定向:避免循环
|
||||
const currentPath = window.location.pathname
|
||||
const basePath = APP_CONFIG.basePath.replace(/\/$/, '') // 移除末尾斜杠
|
||||
|
||||
// 如果当前路径已经是 basePath 或 basePath/,重定向到 api-stats
|
||||
if (currentPath === basePath || currentPath === basePath + '/') {
|
||||
return '/api-stats'
|
||||
}
|
||||
|
||||
// 否则保持默认重定向
|
||||
return '/api-stats'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
@@ -88,6 +100,11 @@ const routes = [
|
||||
component: SettingsView
|
||||
}
|
||||
]
|
||||
},
|
||||
// 捕获所有未匹配的路由
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: '/api-stats'
|
||||
}
|
||||
]
|
||||
|
||||
@@ -103,10 +120,16 @@ router.beforeEach((to, from, next) => {
|
||||
console.log('路由导航:', {
|
||||
to: to.path,
|
||||
from: from.path,
|
||||
fullPath: to.fullPath,
|
||||
requiresAuth: to.meta.requiresAuth,
|
||||
isAuthenticated: authStore.isAuthenticated
|
||||
})
|
||||
|
||||
// 防止重定向循环:如果已经在目标路径,直接放行
|
||||
if (to.path === from.path && to.fullPath === from.fullPath) {
|
||||
return next()
|
||||
}
|
||||
|
||||
// API Stats 页面不需要认证,直接放行
|
||||
if (to.path === '/api-stats' || to.path.startsWith('/api-stats')) {
|
||||
next()
|
||||
|
||||
@@ -11,27 +11,44 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row gap-2 sm:items-center sm:justify-between">
|
||||
<select
|
||||
v-model="accountSortBy"
|
||||
class="form-input px-3 py-2 text-sm w-full sm:w-auto"
|
||||
@change="sortAccounts()"
|
||||
>
|
||||
<option value="name">
|
||||
按名称排序
|
||||
</option>
|
||||
<option value="dailyTokens">
|
||||
按今日Token排序
|
||||
</option>
|
||||
<option value="dailyRequests">
|
||||
按今日请求数排序
|
||||
</option>
|
||||
<option value="totalTokens">
|
||||
按总Token排序
|
||||
</option>
|
||||
<option value="lastUsed">
|
||||
按最后使用排序
|
||||
</option>
|
||||
</select>
|
||||
<div class="flex flex-col sm:flex-row gap-2">
|
||||
<select
|
||||
v-model="accountSortBy"
|
||||
class="form-input px-3 py-2 text-sm w-full sm:w-auto"
|
||||
@change="sortAccounts()"
|
||||
>
|
||||
<option value="name">
|
||||
按名称排序
|
||||
</option>
|
||||
<option value="dailyTokens">
|
||||
按今日Token排序
|
||||
</option>
|
||||
<option value="dailyRequests">
|
||||
按今日请求数排序
|
||||
</option>
|
||||
<option value="totalTokens">
|
||||
按总Token排序
|
||||
</option>
|
||||
<option value="lastUsed">
|
||||
按最后使用排序
|
||||
</option>
|
||||
</select>
|
||||
<select
|
||||
v-model="groupFilter"
|
||||
class="form-input px-3 py-2 text-sm w-full sm:w-auto"
|
||||
@change="filterByGroup"
|
||||
>
|
||||
<option value="all">所有账户</option>
|
||||
<option value="ungrouped">未分组账户</option>
|
||||
<option
|
||||
v-for="group in accountGroups"
|
||||
:key="group.id"
|
||||
:value="group.id"
|
||||
>
|
||||
{{ group.name }} ({{ group.platform === 'claude' ? 'Claude' : 'Gemini' }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-success px-4 sm:px-6 py-2 sm:py-3 flex items-center gap-2 w-full sm:w-auto justify-center"
|
||||
@click.stop="openCreateAccountModal"
|
||||
@@ -69,13 +86,13 @@
|
||||
<!-- 桌面端表格视图 -->
|
||||
<div
|
||||
v-else
|
||||
class="hidden lg:block table-container"
|
||||
class="hidden md:block table-container"
|
||||
>
|
||||
<table class="min-w-full">
|
||||
<table class="w-full table-fixed">
|
||||
<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 cursor-pointer hover:bg-gray-100"
|
||||
class="px-3 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100 w-[22%] min-w-[180px]"
|
||||
@click="sortAccounts('name')"
|
||||
>
|
||||
名称
|
||||
@@ -89,10 +106,10 @@
|
||||
/>
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
|
||||
class="px-3 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100 w-[15%] min-w-[120px]"
|
||||
@click="sortAccounts('platform')"
|
||||
>
|
||||
平台
|
||||
平台/类型
|
||||
<i
|
||||
v-if="accountsSortBy === 'platform'"
|
||||
:class="['fas', accountsSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"
|
||||
@@ -103,21 +120,7 @@
|
||||
/>
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
|
||||
@click="sortAccounts('accountType')"
|
||||
>
|
||||
类型
|
||||
<i
|
||||
v-if="accountsSortBy === 'accountType'"
|
||||
:class="['fas', accountsSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"
|
||||
/>
|
||||
<i
|
||||
v-else
|
||||
class="fas fa-sort ml-1 text-gray-400"
|
||||
/>
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
|
||||
class="px-3 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100 w-[12%] min-w-[100px]"
|
||||
@click="sortAccounts('status')"
|
||||
>
|
||||
状态
|
||||
@@ -131,7 +134,7 @@
|
||||
/>
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
|
||||
class="px-3 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100 w-[8%] min-w-[80px]"
|
||||
@click="sortAccounts('priority')"
|
||||
>
|
||||
优先级
|
||||
@@ -144,19 +147,19 @@
|
||||
class="fas fa-sort ml-1 text-gray-400"
|
||||
/>
|
||||
</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
|
||||
<th class="px-3 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider w-[10%] min-w-[100px]">
|
||||
代理
|
||||
</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
|
||||
<th class="px-3 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider w-[10%] min-w-[90px]">
|
||||
今日使用
|
||||
</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
|
||||
<th class="px-3 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider w-[10%] min-w-[100px]">
|
||||
会话窗口
|
||||
</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
|
||||
<th class="px-3 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider w-[8%] min-w-[80px]">
|
||||
最后使用
|
||||
</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
|
||||
<th class="px-3 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider w-[15%] min-w-[180px]">
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
@@ -167,14 +170,14 @@
|
||||
:key="account.id"
|
||||
class="table-row"
|
||||
>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<td class="px-3 py-4">
|
||||
<div class="flex items-center">
|
||||
<div class="w-8 h-8 bg-gradient-to-br from-green-500 to-green-600 rounded-lg flex items-center justify-center mr-3">
|
||||
<div class="w-8 h-8 bg-gradient-to-br from-green-500 to-green-600 rounded-lg flex items-center justify-center mr-2 flex-shrink-0">
|
||||
<i class="fas fa-user-circle text-white text-xs" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="text-sm font-semibold text-gray-900">
|
||||
<div class="text-sm font-semibold text-gray-900 truncate" :title="account.name">
|
||||
{{ account.name }}
|
||||
</div>
|
||||
<span
|
||||
@@ -183,60 +186,69 @@
|
||||
>
|
||||
<i class="fas fa-lock mr-1" />专属
|
||||
</span>
|
||||
<span
|
||||
v-else-if="account.accountType === 'group'"
|
||||
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800"
|
||||
>
|
||||
<i class="fas fa-layer-group mr-1" />分组调度
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800"
|
||||
>
|
||||
<i class="fas fa-share-alt mr-1" />共享
|
||||
</span>
|
||||
<span
|
||||
v-if="account.groupInfo"
|
||||
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600 ml-1"
|
||||
:title="`所属分组: ${account.groupInfo.name}`"
|
||||
>
|
||||
<i class="fas fa-folder mr-1" />{{ account.groupInfo.name }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
<div class="text-xs text-gray-500 truncate" :title="account.id">
|
||||
{{ account.id }}
|
||||
</div>
|
||||
</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" />Gemini
|
||||
</span>
|
||||
<span
|
||||
v-else-if="account.platform === 'claude-console'"
|
||||
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-purple-100 text-purple-800"
|
||||
>
|
||||
<i class="fas fa-terminal mr-1" />Claude Console
|
||||
</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" />Claude
|
||||
</span>
|
||||
<td class="px-3 py-4">
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- 平台图标和名称 -->
|
||||
<div
|
||||
v-if="account.platform === 'gemini'"
|
||||
class="flex items-center gap-1.5 px-2.5 py-1 bg-gradient-to-r from-yellow-100 to-amber-100 rounded-lg border border-yellow-200"
|
||||
>
|
||||
<i class="fas fa-robot text-yellow-700 text-xs" />
|
||||
<span class="text-xs font-semibold text-yellow-800">Gemini</span>
|
||||
<span class="w-px h-4 bg-yellow-300 mx-1"></span>
|
||||
<span class="text-xs font-medium text-yellow-700">
|
||||
{{ (account.scopes && account.scopes.length > 0) ? 'OAuth' : '传统' }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="account.platform === 'claude-console'"
|
||||
class="flex items-center gap-1.5 px-2.5 py-1 bg-gradient-to-r from-purple-100 to-pink-100 rounded-lg border border-purple-200"
|
||||
>
|
||||
<i class="fas fa-terminal text-purple-700 text-xs" />
|
||||
<span class="text-xs font-semibold text-purple-800">Console</span>
|
||||
<span class="w-px h-4 bg-purple-300 mx-1"></span>
|
||||
<span class="text-xs font-medium text-purple-700">API Key</span>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex items-center gap-1.5 px-2.5 py-1 bg-gradient-to-r from-indigo-100 to-blue-100 rounded-lg border border-indigo-200"
|
||||
>
|
||||
<i class="fas fa-brain text-indigo-700 text-xs" />
|
||||
<span class="text-xs font-semibold text-indigo-800">Claude</span>
|
||||
<span class="w-px h-4 bg-indigo-300 mx-1"></span>
|
||||
<span class="text-xs font-medium text-indigo-700">
|
||||
{{ (account.scopes && account.scopes.length > 0) ? 'OAuth' : '传统' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
v-if="account.platform === 'claude-console'"
|
||||
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-green-100 text-green-800"
|
||||
>
|
||||
<i class="fas fa-key mr-1" />API Key
|
||||
</span>
|
||||
<span
|
||||
v-else-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"
|
||||
>
|
||||
<i class="fas fa-lock mr-1" />OAuth
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-orange-100 text-orange-800"
|
||||
>
|
||||
<i class="fas fa-key mr-1" />传统
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<td class="px-3 py-4 whitespace-nowrap">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span
|
||||
:class="['inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold',
|
||||
@@ -279,7 +291,7 @@
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<td class="px-3 py-4 whitespace-nowrap">
|
||||
<div
|
||||
v-if="account.platform === 'claude' || account.platform === 'claude-console'"
|
||||
class="flex items-center gap-2"
|
||||
@@ -301,10 +313,11 @@
|
||||
<span class="text-xs">N/A</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
|
||||
<td class="px-3 py-4 text-sm text-gray-600">
|
||||
<div
|
||||
v-if="formatProxyDisplay(account.proxy)"
|
||||
class="text-xs bg-blue-50 px-2 py-1 rounded font-mono"
|
||||
class="text-xs bg-blue-50 px-2 py-1 rounded font-mono break-all"
|
||||
:title="formatProxyDisplay(account.proxy)"
|
||||
>
|
||||
{{ formatProxyDisplay(account.proxy) }}
|
||||
</div>
|
||||
@@ -315,7 +328,7 @@
|
||||
无代理
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<td class="px-3 py-4 whitespace-nowrap text-sm">
|
||||
<div
|
||||
v-if="account.usage && account.usage.daily"
|
||||
class="space-y-1"
|
||||
@@ -342,7 +355,7 @@
|
||||
暂无数据
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<td class="px-3 py-4 whitespace-nowrap">
|
||||
<div
|
||||
v-if="account.platform === 'claude' && account.sessionWindow && account.sessionWindow.hasActiveWindow"
|
||||
class="space-y-2"
|
||||
@@ -381,16 +394,16 @@
|
||||
<span class="text-xs">N/A</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
|
||||
<td class="px-3 py-4 whitespace-nowrap text-sm text-gray-600">
|
||||
{{ formatLastUsed(account.lastUsedAt) }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<div class="flex items-center gap-2">
|
||||
<td class="px-3 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<div class="flex items-center gap-1 flex-wrap">
|
||||
<button
|
||||
v-if="account.platform === 'claude' && account.scopes"
|
||||
:disabled="account.isRefreshing"
|
||||
:class="[
|
||||
'px-3 py-1.5 rounded-lg text-xs font-medium transition-colors',
|
||||
'px-2.5 py-1 rounded text-xs font-medium transition-colors',
|
||||
account.isRefreshing
|
||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-blue-100 text-blue-700 hover:bg-blue-200'
|
||||
@@ -404,11 +417,12 @@
|
||||
account.isRefreshing ? 'animate-spin' : ''
|
||||
]"
|
||||
/>
|
||||
<span class="ml-1">刷新</span>
|
||||
</button>
|
||||
<button
|
||||
:disabled="account.isTogglingSchedulable"
|
||||
:class="[
|
||||
'px-3 py-1.5 rounded-lg text-xs font-medium transition-colors',
|
||||
'px-2.5 py-1 rounded text-xs font-medium transition-colors',
|
||||
account.isTogglingSchedulable
|
||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
: account.schedulable
|
||||
@@ -424,18 +438,23 @@
|
||||
account.schedulable ? 'fa-toggle-on' : 'fa-toggle-off'
|
||||
]"
|
||||
/>
|
||||
<span class="ml-1">{{ account.schedulable ? '调度' : '停用' }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-1.5 bg-blue-100 text-blue-700 rounded-lg text-xs font-medium hover:bg-blue-200 transition-colors"
|
||||
class="px-2.5 py-1 bg-blue-100 text-blue-700 rounded text-xs font-medium hover:bg-blue-200 transition-colors"
|
||||
:title="'编辑账户'"
|
||||
@click="editAccount(account)"
|
||||
>
|
||||
<i class="fas fa-edit" />
|
||||
<span class="ml-1">编辑</span>
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-1.5 bg-red-100 text-red-700 rounded-lg text-xs font-medium hover:bg-red-200 transition-colors"
|
||||
class="px-2.5 py-1 bg-red-100 text-red-700 rounded text-xs font-medium hover:bg-red-200 transition-colors"
|
||||
:title="'删除账户'"
|
||||
@click="deleteAccount(account)"
|
||||
>
|
||||
<i class="fas fa-trash" />
|
||||
<span class="ml-1">删除</span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
@@ -447,7 +466,7 @@
|
||||
<!-- 移动端卡片视图 -->
|
||||
<div
|
||||
v-if="!accountsLoading && sortedAccounts.length > 0"
|
||||
class="lg:hidden space-y-3"
|
||||
class="md:hidden space-y-3"
|
||||
>
|
||||
<div
|
||||
v-for="account in sortedAccounts"
|
||||
@@ -670,6 +689,9 @@ const accountsSortBy = ref('')
|
||||
const accountsSortOrder = ref('asc')
|
||||
const apiKeys = ref([])
|
||||
const refreshingTokens = ref({})
|
||||
const accountGroups = ref([])
|
||||
const groupFilter = ref('all')
|
||||
const filteredAccounts = ref([])
|
||||
|
||||
// 模态框状态
|
||||
const showCreateAccountModal = ref(false)
|
||||
@@ -678,9 +700,10 @@ const editingAccount = ref(null)
|
||||
|
||||
// 计算排序后的账户列表
|
||||
const sortedAccounts = computed(() => {
|
||||
if (!accountsSortBy.value) return accounts.value
|
||||
const sourceAccounts = filteredAccounts.value.length > 0 ? filteredAccounts.value : accounts.value
|
||||
if (!accountsSortBy.value) return sourceAccounts
|
||||
|
||||
const sorted = [...accounts.value].sort((a, b) => {
|
||||
const sorted = [...sourceAccounts].sort((a, b) => {
|
||||
let aVal = a[accountsSortBy.value]
|
||||
let bVal = b[accountsSortBy.value]
|
||||
|
||||
@@ -720,11 +743,12 @@ const sortedAccounts = computed(() => {
|
||||
const loadAccounts = async () => {
|
||||
accountsLoading.value = true
|
||||
try {
|
||||
const [claudeData, claudeConsoleData, geminiData, apiKeysData] = await Promise.all([
|
||||
const [claudeData, claudeConsoleData, geminiData, apiKeysData, groupsData] = await Promise.all([
|
||||
apiClient.get('/admin/claude-accounts'),
|
||||
apiClient.get('/admin/claude-console-accounts'),
|
||||
apiClient.get('/admin/gemini-accounts'),
|
||||
apiClient.get('/admin/api-keys')
|
||||
apiClient.get('/admin/api-keys'),
|
||||
apiClient.get('/admin/account-groups')
|
||||
])
|
||||
|
||||
// 更新API Keys列表
|
||||
@@ -732,22 +756,49 @@ const loadAccounts = async () => {
|
||||
apiKeys.value = apiKeysData.data || []
|
||||
}
|
||||
|
||||
// 更新分组列表
|
||||
if (groupsData.success) {
|
||||
accountGroups.value = groupsData.data || []
|
||||
}
|
||||
|
||||
// 创建分组ID到分组信息的映射
|
||||
const groupMap = new Map()
|
||||
const accountGroupMap = new Map()
|
||||
|
||||
// 获取所有分组的成员信息
|
||||
for (const group of accountGroups.value) {
|
||||
groupMap.set(group.id, group)
|
||||
try {
|
||||
const membersResponse = await apiClient.get(`/admin/account-groups/${group.id}/members`)
|
||||
if (membersResponse.success) {
|
||||
const members = membersResponse.data || []
|
||||
members.forEach(member => {
|
||||
accountGroupMap.set(member.id, group)
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to load members for group ${group.id}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
const allAccounts = []
|
||||
|
||||
if (claudeData.success) {
|
||||
const claudeAccounts = (claudeData.data || []).map(acc => {
|
||||
// 计算每个Claude账户绑定的API Key数量
|
||||
const boundApiKeysCount = apiKeys.value.filter(key => key.claudeAccountId === acc.id).length
|
||||
return { ...acc, platform: 'claude', boundApiKeysCount }
|
||||
// 检查是否属于某个分组
|
||||
const groupInfo = accountGroupMap.get(acc.id) || null
|
||||
return { ...acc, platform: 'claude', boundApiKeysCount, groupInfo }
|
||||
})
|
||||
allAccounts.push(...claudeAccounts)
|
||||
}
|
||||
|
||||
if (claudeConsoleData.success) {
|
||||
const claudeConsoleAccounts = (claudeConsoleData.data || []).map(acc => {
|
||||
// 计算每个Claude Console账户绑定的API Key数量
|
||||
const boundApiKeysCount = apiKeys.value.filter(key => key.claudeConsoleAccountId === acc.id).length
|
||||
return { ...acc, platform: 'claude-console', boundApiKeysCount }
|
||||
// Claude Console账户暂时不支持直接绑定
|
||||
const groupInfo = accountGroupMap.get(acc.id) || null
|
||||
return { ...acc, platform: 'claude-console', boundApiKeysCount: 0, groupInfo }
|
||||
})
|
||||
allAccounts.push(...claudeConsoleAccounts)
|
||||
}
|
||||
@@ -756,12 +807,15 @@ const loadAccounts = async () => {
|
||||
const geminiAccounts = (geminiData.data || []).map(acc => {
|
||||
// 计算每个Gemini账户绑定的API Key数量
|
||||
const boundApiKeysCount = apiKeys.value.filter(key => key.geminiAccountId === acc.id).length
|
||||
return { ...acc, platform: 'gemini', boundApiKeysCount }
|
||||
const groupInfo = accountGroupMap.get(acc.id) || null
|
||||
return { ...acc, platform: 'gemini', boundApiKeysCount, groupInfo }
|
||||
})
|
||||
allAccounts.push(...geminiAccounts)
|
||||
}
|
||||
|
||||
accounts.value = allAccounts
|
||||
// 初始化过滤后的账户列表
|
||||
filterByGroup()
|
||||
} catch (error) {
|
||||
showToast('加载账户失败', 'error')
|
||||
} finally {
|
||||
@@ -819,19 +873,38 @@ const loadApiKeys = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 按分组筛选账户
|
||||
const filterByGroup = () => {
|
||||
if (groupFilter.value === 'all') {
|
||||
filteredAccounts.value = accounts.value
|
||||
} else if (groupFilter.value === 'ungrouped') {
|
||||
filteredAccounts.value = accounts.value.filter(acc => !acc.groupInfo)
|
||||
} else {
|
||||
// 按特定分组筛选
|
||||
filteredAccounts.value = accounts.value.filter(acc =>
|
||||
acc.groupInfo && acc.groupInfo.id === groupFilter.value
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化代理信息显示
|
||||
const formatProxyDisplay = (proxy) => {
|
||||
if (!proxy || !proxy.host || !proxy.port) return null
|
||||
|
||||
let display = `${proxy.type}://${proxy.host}:${proxy.port}`
|
||||
// 缩短类型名称
|
||||
const typeShort = proxy.type === 'socks5' ? 'S5' : proxy.type.toUpperCase()
|
||||
|
||||
// 缩短主机名(如果太长)
|
||||
let host = proxy.host
|
||||
if (host.length > 15) {
|
||||
host = host.substring(0, 12) + '...'
|
||||
}
|
||||
|
||||
let display = `${typeShort}://${host}:${proxy.port}`
|
||||
|
||||
// 如果有用户名密码,添加认证信息(部分隐藏)
|
||||
if (proxy.username) {
|
||||
const maskedUsername = proxy.username.length > 2
|
||||
? proxy.username[0] + '***' + proxy.username[proxy.username.length - 1]
|
||||
: '***'
|
||||
const maskedPassword = proxy.password ? '****' : ''
|
||||
display = `${proxy.type}://${maskedUsername}:${maskedPassword}@${proxy.host}:${proxy.port}`
|
||||
display = `${typeShort}://***@${host}:${proxy.port}`
|
||||
}
|
||||
|
||||
return display
|
||||
@@ -1104,6 +1177,33 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.table-row {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.table-row:hover {
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-top: 2px solid #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
.accounts-container {
|
||||
min-height: calc(100vh - 300px);
|
||||
}
|
||||
|
||||
@@ -88,11 +88,11 @@
|
||||
v-else
|
||||
class="hidden md:block table-container"
|
||||
>
|
||||
<table class="min-w-full">
|
||||
<table class="w-full table-fixed">
|
||||
<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 cursor-pointer hover:bg-gray-100"
|
||||
class="px-3 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100 w-[25%] min-w-[200px]"
|
||||
@click="sortApiKeys('name')"
|
||||
>
|
||||
名称
|
||||
@@ -105,11 +105,11 @@
|
||||
class="fas fa-sort ml-1 text-gray-400"
|
||||
/>
|
||||
</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
|
||||
<th class="px-3 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider w-[10%] min-w-[80px]">
|
||||
标签
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
|
||||
class="px-3 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100 w-[8%] min-w-[70px]"
|
||||
@click="sortApiKeys('status')"
|
||||
>
|
||||
状态
|
||||
@@ -122,7 +122,7 @@
|
||||
class="fas fa-sort ml-1 text-gray-400"
|
||||
/>
|
||||
</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
|
||||
<th class="px-3 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider w-[17%] min-w-[140px]">
|
||||
使用统计
|
||||
<span
|
||||
class="cursor-pointer hover:bg-gray-100 px-2 py-1 rounded"
|
||||
@@ -140,7 +140,7 @@
|
||||
</span>
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
|
||||
class="px-3 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100 w-[10%] min-w-[90px]"
|
||||
@click="sortApiKeys('createdAt')"
|
||||
>
|
||||
创建时间
|
||||
@@ -154,7 +154,7 @@
|
||||
/>
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
|
||||
class="px-3 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100 w-[10%] min-w-[90px]"
|
||||
@click="sortApiKeys('expiresAt')"
|
||||
>
|
||||
过期时间
|
||||
@@ -167,7 +167,7 @@
|
||||
class="fas fa-sort ml-1 text-gray-400"
|
||||
/>
|
||||
</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
|
||||
<th class="px-3 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider w-[20%] min-w-[180px]">
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
@@ -179,32 +179,32 @@
|
||||
>
|
||||
<!-- API Key 主行 -->
|
||||
<tr class="table-row">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<td class="px-3 py-4">
|
||||
<div class="flex items-center">
|
||||
<div class="w-8 h-8 bg-gradient-to-br from-blue-500 to-blue-600 rounded-lg flex items-center justify-center mr-3">
|
||||
<div class="w-8 h-8 bg-gradient-to-br from-blue-500 to-blue-600 rounded-lg flex items-center justify-center mr-2 flex-shrink-0">
|
||||
<i class="fas fa-key text-white text-xs" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-gray-900">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-semibold text-gray-900 truncate" :title="key.name">
|
||||
{{ key.name }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
<div class="text-xs text-gray-500 truncate" :title="key.id">
|
||||
{{ key.id }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 mt-1">
|
||||
<span v-if="key.claudeAccountId || key.claudeConsoleAccountId">
|
||||
<div class="text-xs text-gray-500 mt-1 truncate">
|
||||
<span v-if="key.claudeAccountId" :title="`绑定: ${getBoundAccountName(key.claudeAccountId)}`">
|
||||
<i class="fas fa-link mr-1" />
|
||||
绑定: {{ getBoundAccountName(key.claudeAccountId, key.claudeConsoleAccountId) }}
|
||||
{{ getBoundAccountName(key.claudeAccountId) }}
|
||||
</span>
|
||||
<span v-else>
|
||||
<i class="fas fa-share-alt mr-1" />
|
||||
使用共享池
|
||||
共享池
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<td class="px-3 py-4">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="tag in (key.tags || [])"
|
||||
@@ -219,7 +219,7 @@
|
||||
>无标签</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<td class="px-3 py-4 whitespace-nowrap">
|
||||
<span
|
||||
:class="['inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold',
|
||||
key.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800']"
|
||||
@@ -231,7 +231,7 @@
|
||||
{{ key.isActive ? '活跃' : '禁用' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<td class="px-3 py-4">
|
||||
<div class="space-y-1">
|
||||
<!-- 请求统计 -->
|
||||
<div class="flex justify-between text-sm">
|
||||
@@ -328,10 +328,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<td class="px-3 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ new Date(key.createdAt).toLocaleDateString() }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<td class="px-3 py-4 whitespace-nowrap text-sm">
|
||||
<div class="inline-flex items-center gap-1 group">
|
||||
<span v-if="key.expiresAt">
|
||||
<span
|
||||
@@ -371,33 +371,40 @@
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<div class="flex gap-2">
|
||||
<td class="px-3 py-4 whitespace-nowrap text-sm">
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
class="text-purple-600 hover:text-purple-900 font-medium hover:bg-purple-50 px-3 py-1 rounded-lg transition-colors"
|
||||
class="text-purple-600 hover:text-purple-900 font-medium hover:bg-purple-50 px-2 py-1 rounded transition-colors text-xs"
|
||||
title="复制统计页面链接"
|
||||
@click="copyApiStatsLink(key)"
|
||||
>
|
||||
<i class="fas fa-chart-bar mr-1" />统计
|
||||
<i class="fas fa-chart-bar" />
|
||||
<span class="hidden xl:inline ml-1">统计</span>
|
||||
</button>
|
||||
<button
|
||||
class="text-blue-600 hover:text-blue-900 font-medium hover:bg-blue-50 px-3 py-1 rounded-lg transition-colors"
|
||||
class="text-blue-600 hover:text-blue-900 font-medium hover:bg-blue-50 px-2 py-1 rounded transition-colors text-xs"
|
||||
title="编辑"
|
||||
@click="openEditApiKeyModal(key)"
|
||||
>
|
||||
<i class="fas fa-edit mr-1" />编辑
|
||||
<i class="fas fa-edit" />
|
||||
<span class="hidden xl:inline ml-1">编辑</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="key.expiresAt && (isApiKeyExpired(key.expiresAt) || isApiKeyExpiringSoon(key.expiresAt))"
|
||||
class="text-green-600 hover:text-green-900 font-medium hover:bg-green-50 px-3 py-1 rounded-lg transition-colors"
|
||||
class="text-green-600 hover:text-green-900 font-medium hover:bg-green-50 px-2 py-1 rounded transition-colors text-xs"
|
||||
title="续期"
|
||||
@click="openRenewApiKeyModal(key)"
|
||||
>
|
||||
<i class="fas fa-clock mr-1" />续期
|
||||
<i class="fas fa-clock" />
|
||||
<span class="hidden xl:inline ml-1">续期</span>
|
||||
</button>
|
||||
<button
|
||||
class="text-red-600 hover:text-red-900 font-medium hover:bg-red-50 px-3 py-1 rounded-lg transition-colors"
|
||||
class="text-red-600 hover:text-red-900 font-medium hover:bg-red-50 px-2 py-1 rounded transition-colors text-xs"
|
||||
title="删除"
|
||||
@click="deleteApiKey(key.id)"
|
||||
>
|
||||
<i class="fas fa-trash mr-1" />删除
|
||||
<i class="fas fa-trash" />
|
||||
<span class="hidden xl:inline ml-1">删除</span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
@@ -406,8 +413,8 @@
|
||||
<!-- 模型统计展开区域 -->
|
||||
<tr v-if="key && key.id && expandedApiKeys[key.id]">
|
||||
<td
|
||||
colspan="6"
|
||||
class="px-6 py-4 bg-gray-50"
|
||||
colspan="7"
|
||||
class="px-3 py-4 bg-gray-50"
|
||||
>
|
||||
<div
|
||||
v-if="!apiKeyModelStats[key.id]"
|
||||
|
||||
Reference in New Issue
Block a user