Merge branch 'main' into um-5

This commit is contained in:
Feng Yue
2025-08-25 17:19:24 +08:00
74 changed files with 9466 additions and 1901 deletions

View File

@@ -30,6 +30,8 @@ CLAUDE_BETA_HEADER=claude-code-20250219,oauth-2025-04-20,interleaved-thinking-20
# 🌐 代理配置 # 🌐 代理配置
DEFAULT_PROXY_TIMEOUT=60000 DEFAULT_PROXY_TIMEOUT=60000
MAX_PROXY_RETRIES=3 MAX_PROXY_RETRIES=3
# IP协议族配置true=IPv4, false=IPv6, 默认IPv4兼容性更好
PROXY_USE_IPV4=true
# 📈 使用限制 # 📈 使用限制
DEFAULT_TOKEN_LIMIT=1000000 DEFAULT_TOKEN_LIMIT=1000000
@@ -43,8 +45,7 @@ LOG_MAX_FILES=5
CLEANUP_INTERVAL=3600000 CLEANUP_INTERVAL=3600000
TOKEN_USAGE_RETENTION=2592000000 TOKEN_USAGE_RETENTION=2592000000
HEALTH_CHECK_INTERVAL=60000 HEALTH_CHECK_INTERVAL=60000
SYSTEM_TIMEZONE=Asia/Shanghai TIMEZONE_OFFSET=8 # UTC偏移小时数默认+8中国时区
TIMEZONE_OFFSET=8
METRICS_WINDOW=5 # 实时指标统计窗口分钟可选1-60默认5分钟 METRICS_WINDOW=5 # 实时指标统计窗口分钟可选1-60默认5分钟
# 🎨 Web 界面配置 # 🎨 Web 界面配置

4
.gitignore vendored
View File

@@ -13,6 +13,10 @@ pnpm-debug.log*
# Claude specific directories # Claude specific directories
.claude/ .claude/
# MCP configuration (local only)
.mcp.json
.spec-workflow/
# Data directory (contains sensitive information) # Data directory (contains sensitive information)
data/ data/
!data/.gitkeep !data/.gitkeep

View File

@@ -11,12 +11,14 @@ Claude Relay Service 是一个功能完整的 AI API 中转服务,支持 Claud
## 核心架构 ## 核心架构
### 关键架构概念 ### 关键架构概念
- **代理认证流**: 客户端用自建API Key → 验证 → 获取Claude账户OAuth token → 转发到Anthropic - **代理认证流**: 客户端用自建API Key → 验证 → 获取Claude账户OAuth token → 转发到Anthropic
- **Token管理**: 自动监控OAuth token过期并刷新支持10秒提前刷新策略 - **Token管理**: 自动监控OAuth token过期并刷新支持10秒提前刷新策略
- **代理支持**: 每个Claude账户支持独立代理配置OAuth token交换也通过代理进行 - **代理支持**: 每个Claude账户支持独立代理配置OAuth token交换也通过代理进行
- **数据加密**: 敏感数据refreshToken, accessToken使用AES加密存储在Redis - **数据加密**: 敏感数据refreshToken, accessToken使用AES加密存储在Redis
### 主要服务组件 ### 主要服务组件
- **claudeRelayService.js**: 核心代理服务,处理请求转发和流式响应 - **claudeRelayService.js**: 核心代理服务,处理请求转发和流式响应
- **claudeAccountService.js**: Claude账户管理OAuth token刷新和账户选择 - **claudeAccountService.js**: Claude账户管理OAuth token刷新和账户选择
- **geminiAccountService.js**: Gemini账户管理Google OAuth token刷新和账户选择 - **geminiAccountService.js**: Gemini账户管理Google OAuth token刷新和账户选择
@@ -24,7 +26,8 @@ Claude Relay Service 是一个功能完整的 AI API 中转服务,支持 Claud
- **oauthHelper.js**: OAuth工具PKCE流程实现和代理支持 - **oauthHelper.js**: OAuth工具PKCE流程实现和代理支持
### 认证和代理流程 ### 认证和代理流程
1. 客户端使用自建API Keycr_前缀格式发送请求
1. 客户端使用自建API Keycr\_前缀格式发送请求
2. authenticateApiKey中间件验证API Key有效性和速率限制 2. authenticateApiKey中间件验证API Key有效性和速率限制
3. claudeAccountService自动选择可用Claude账户 3. claudeAccountService自动选择可用Claude账户
4. 检查OAuth access token有效性过期则自动刷新使用代理 4. 检查OAuth access token有效性过期则自动刷新使用代理
@@ -33,6 +36,7 @@ Claude Relay Service 是一个功能完整的 AI API 中转服务,支持 Claud
7. 流式或非流式返回响应,记录使用统计 7. 流式或非流式返回响应,记录使用统计
### OAuth集成 ### OAuth集成
- **PKCE流程**: 完整的OAuth 2.0 PKCE实现支持代理 - **PKCE流程**: 完整的OAuth 2.0 PKCE实现支持代理
- **自动刷新**: 智能token过期检测和自动刷新机制 - **自动刷新**: 智能token过期检测和自动刷新机制
- **代理支持**: OAuth授权和token交换全程支持代理配置 - **代理支持**: OAuth授权和token交换全程支持代理配置
@@ -41,7 +45,8 @@ Claude Relay Service 是一个功能完整的 AI API 中转服务,支持 Claud
## 常用命令 ## 常用命令
### 基本开发命令 ### 基本开发命令
```bash
````bash
# 安装依赖和初始化 # 安装依赖和初始化
npm install npm install
npm run setup # 生成配置和管理员凭据 npm run setup # 生成配置和管理员凭据
@@ -76,11 +81,12 @@ npm run service:stop # 停止服务
cp config/config.example.js config/config.js cp config/config.example.js config/config.js
cp .env.example .env cp .env.example .env
npm run setup # 自动生成密钥并创建管理员账户 npm run setup # 自动生成密钥并创建管理员账户
``` ````
## Web界面功能 ## Web界面功能
### OAuth账户添加流程 ### OAuth账户添加流程
1. **基本信息和代理设置**: 配置账户名称、描述和代理参数 1. **基本信息和代理设置**: 配置账户名称、描述和代理参数
2. **OAuth授权**: 2. **OAuth授权**:
- 生成授权URL → 用户打开链接并登录Claude Code账号 - 生成授权URL → 用户打开链接并登录Claude Code账号
@@ -88,25 +94,30 @@ npm run setup # 自动生成密钥并创建管理员账户
- 系统自动交换token并创建账户 - 系统自动交换token并创建账户
### 核心管理功能 ### 核心管理功能
- **实时仪表板**: 系统统计、账户状态、使用量监控 - **实时仪表板**: 系统统计、账户状态、使用量监控
- **API Key管理**: 创建、配额设置、使用统计查看 - **API Key管理**: 创建、配额设置、使用统计查看
- **Claude账户管理**: OAuth账户添加、代理配置、状态监控 - **Claude账户管理**: OAuth账户添加、代理配置、状态监控
- **系统日志**: 实时日志查看,多级别过滤 - **系统日志**: 实时日志查看,多级别过滤
- **主题系统**: 支持明亮/暗黑模式切换,自动保存用户偏好设置
## 重要端点 ## 重要端点
### API转发端点 ### API转发端点
- `POST /api/v1/messages` - 主要消息处理端点(支持流式) - `POST /api/v1/messages` - 主要消息处理端点(支持流式)
- `GET /api/v1/models` - 模型列表(兼容性) - `GET /api/v1/models` - 模型列表(兼容性)
- `GET /api/v1/usage` - 使用统计查询 - `GET /api/v1/usage` - 使用统计查询
- `GET /api/v1/key-info` - API Key信息 - `GET /api/v1/key-info` - API Key信息
### OAuth管理端点 ### OAuth管理端点
- `POST /admin/claude-accounts/generate-auth-url` - 生成OAuth授权URL含代理 - `POST /admin/claude-accounts/generate-auth-url` - 生成OAuth授权URL含代理
- `POST /admin/claude-accounts/exchange-code` - 交换authorization code - `POST /admin/claude-accounts/exchange-code` - 交换authorization code
- `POST /admin/claude-accounts` - 创建OAuth账户 - `POST /admin/claude-accounts` - 创建OAuth账户
### 系统端点 ### 系统端点
- `GET /health` - 健康检查 - `GET /health` - 健康检查
- `GET /web` - Web管理界面 - `GET /web` - Web管理界面
- `GET /admin/dashboard` - 系统概览数据 - `GET /admin/dashboard` - 系统概览数据
@@ -114,22 +125,26 @@ npm run setup # 自动生成密钥并创建管理员账户
## 故障排除 ## 故障排除
### OAuth相关问题 ### OAuth相关问题
1. **代理配置错误**: 检查代理设置是否正确OAuth token交换也需要代理 1. **代理配置错误**: 检查代理设置是否正确OAuth token交换也需要代理
2. **授权码无效**: 确保复制了完整的Authorization Code没有遗漏字符 2. **授权码无效**: 确保复制了完整的Authorization Code没有遗漏字符
3. **Token刷新失败**: 检查refreshToken有效性和代理配置 3. **Token刷新失败**: 检查refreshToken有效性和代理配置
### Gemini Token刷新问题 ### Gemini Token刷新问题
1. **刷新失败**: 确保 refresh_token 有效且未过期 1. **刷新失败**: 确保 refresh_token 有效且未过期
2. **错误日志**: 查看 `logs/token-refresh-error.log` 获取详细错误信息 2. **错误日志**: 查看 `logs/token-refresh-error.log` 获取详细错误信息
3. **测试脚本**: 运行 `node scripts/test-gemini-refresh.js` 测试 token 刷新 3. **测试脚本**: 运行 `node scripts/test-gemini-refresh.js` 测试 token 刷新
### 常见开发问题 ### 常见开发问题
1. **Redis连接失败**: 确认Redis服务运行检查连接配置 1. **Redis连接失败**: 确认Redis服务运行检查连接配置
2. **管理员登录失败**: 检查init.json同步到Redis运行npm run setup 2. **管理员登录失败**: 检查init.json同步到Redis运行npm run setup
3. **API Key格式错误**: 确保使用cr_前缀格式 3. **API Key格式错误**: 确保使用cr\_前缀格式
4. **代理连接问题**: 验证SOCKS5/HTTP代理配置和认证信息 4. **代理连接问题**: 验证SOCKS5/HTTP代理配置和认证信息
### 调试工具 ### 调试工具
- **日志系统**: Winston结构化日志支持不同级别 - **日志系统**: Winston结构化日志支持不同级别
- **CLI工具**: 命令行状态查看和管理 - **CLI工具**: 命令行状态查看和管理
- **Web界面**: 实时日志查看和系统监控 - **Web界面**: 实时日志查看和系统监控
@@ -138,19 +153,35 @@ npm run setup # 自动生成密钥并创建管理员账户
## 开发最佳实践 ## 开发最佳实践
### 代码格式化要求 ### 代码格式化要求
- **必须使用 Prettier 格式化所有代码** - **必须使用 Prettier 格式化所有代码**
- 后端代码src/):运行 `npx prettier --write <file>` 格式化 - 后端代码src/):运行 `npx prettier --write <file>` 格式化
- 前端代码web/admin-spa/):已安装 `prettier-plugin-tailwindcss`,运行 `npx prettier --write <file>` 格式化 - 前端代码web/admin-spa/):已安装 `prettier-plugin-tailwindcss`,运行 `npx prettier --write <file>` 格式化
- 提交前检查格式:`npx prettier --check <file>` - 提交前检查格式:`npx prettier --check <file>`
- 格式化所有文件:`npm run format`(如果配置了此脚本) - 格式化所有文件:`npm run format`(如果配置了此脚本)
### 前端开发特殊要求
- **响应式设计**: 必须兼容不同设备尺寸(手机、平板、桌面),使用 Tailwind CSS 响应式前缀sm:、md:、lg:、xl:
- **暗黑模式兼容**: 项目已集成完整的暗黑模式支持,所有新增/修改的UI组件都必须同时兼容明亮模式和暗黑模式
- 使用 Tailwind CSS 的 `dark:` 前缀为暗黑模式提供样式
- 文本颜色:`text-gray-700 dark:text-gray-200`
- 背景颜色:`bg-white dark:bg-gray-800`
- 边框颜色:`border-gray-200 dark:border-gray-700`
- 状态颜色保持一致:`text-blue-500`、`text-green-600`、`text-red-500` 等
- **主题切换**: 使用 `stores/theme.js` 中的 `useThemeStore()` 来实现主题切换功能
- **玻璃态效果**: 保持现有的玻璃态设计风格,在暗黑模式下调整透明度和背景色
- **图标和交互**: 确保所有图标、按钮、交互元素在两种模式下都清晰可见且易于操作
### 代码修改原则 ### 代码修改原则
- 对现有文件进行修改时,首先检查代码库的现有模式和风格 - 对现有文件进行修改时,首先检查代码库的现有模式和风格
- 尽可能重用现有的服务和工具函数,避免重复代码 - 尽可能重用现有的服务和工具函数,避免重复代码
- 遵循项目现有的错误处理和日志记录模式 - 遵循项目现有的错误处理和日志记录模式
- 敏感数据必须使用加密存储(参考 claudeAccountService.js 中的加密实现) - 敏感数据必须使用加密存储(参考 claudeAccountService.js 中的加密实现)
### 测试和质量保证 ### 测试和质量保证
- 运行 `npm run lint` 进行代码风格检查(使用 ESLint - 运行 `npm run lint` 进行代码风格检查(使用 ESLint
- 运行 `npm test` 执行测试套件Jest + SuperTest 配置) - 运行 `npm test` 执行测试套件Jest + SuperTest 配置)
- 在修改核心服务后,使用 CLI 工具验证功能:`npm run cli status` - 在修改核心服务后,使用 CLI 工具验证功能:`npm run cli status`
@@ -158,20 +189,26 @@ npm run setup # 自动生成密钥并创建管理员账户
- 注意:当前项目缺少实际测试文件,建议补充单元测试和集成测试 - 注意:当前项目缺少实际测试文件,建议补充单元测试和集成测试
### 开发工作流 ### 开发工作流
- **功能开发**: 始终从理解现有代码开始,重用已有的服务和模式 - **功能开发**: 始终从理解现有代码开始,重用已有的服务和模式
- **调试流程**: 使用 Winston 日志 + Web 界面实时日志查看 + CLI 状态工具 - **调试流程**: 使用 Winston 日志 + Web 界面实时日志查看 + CLI 状态工具
- **代码审查**: 关注安全性(加密存储)、性能(异步处理)、错误处理 - **代码审查**: 关注安全性(加密存储)、性能(异步处理)、错误处理
- **部署前检查**: 运行 lint → 测试 CLI 功能 → 检查日志 → Docker 构建 - **部署前检查**: 运行 lint → 测试 CLI 功能 → 检查日志 → Docker 构建
### 常见文件位置 ### 常见文件位置
- 核心服务逻辑:`src/services/` 目录 - 核心服务逻辑:`src/services/` 目录
- 路由处理:`src/routes/` 目录 - 路由处理:`src/routes/` 目录
- 中间件:`src/middleware/` 目录 - 中间件:`src/middleware/` 目录
- 配置管理:`config/config.js` - 配置管理:`config/config.js`
- Redis 模型:`src/models/redis.js` - Redis 模型:`src/models/redis.js`
- 工具函数:`src/utils/` 目录 - 工具函数:`src/utils/` 目录
- 前端主题管理:`web/admin-spa/src/stores/theme.js`
- 前端组件:`web/admin-spa/src/components/` 目录
- 前端页面:`web/admin-spa/src/views/` 目录
### 重要架构决策 ### 重要架构决策
- 所有敏感数据OAuth token、refreshToken都使用 AES 加密存储在 Redis - 所有敏感数据OAuth token、refreshToken都使用 AES 加密存储在 Redis
- 每个 Claude 账户支持独立的代理配置,包括 SOCKS5 和 HTTP 代理 - 每个 Claude 账户支持独立的代理配置,包括 SOCKS5 和 HTTP 代理
- API Key 使用哈希存储,支持 `cr_` 前缀格式 - API Key 使用哈希存储,支持 `cr_` 前缀格式
@@ -179,6 +216,7 @@ npm run setup # 自动生成密钥并创建管理员账户
- 支持流式和非流式响应,客户端断开时自动清理资源 - 支持流式和非流式响应,客户端断开时自动清理资源
### 核心数据流和性能优化 ### 核心数据流和性能优化
- **哈希映射优化**: API Key 验证从 O(n) 优化到 O(1) 查找 - **哈希映射优化**: API Key 验证从 O(n) 优化到 O(1) 查找
- **智能 Usage 捕获**: 从 SSE 流中解析真实的 token 使用数据 - **智能 Usage 捕获**: 从 SSE 流中解析真实的 token 使用数据
- **多维度统计**: 支持按时间、模型、用户的实时使用统计 - **多维度统计**: 支持按时间、模型、用户的实时使用统计
@@ -186,6 +224,7 @@ npm run setup # 自动生成密钥并创建管理员账户
- **原子操作**: Redis 管道操作确保数据一致性 - **原子操作**: Redis 管道操作确保数据一致性
### 安全和容错机制 ### 安全和容错机制
- **多层加密**: API Key 哈希 + OAuth Token AES 加密 - **多层加密**: API Key 哈希 + OAuth Token AES 加密
- **零信任验证**: 每个请求都需要完整的认证链 - **零信任验证**: 每个请求都需要完整的认证链
- **优雅降级**: Redis 连接失败时的回退机制 - **优雅降级**: Redis 连接失败时的回退机制
@@ -195,6 +234,7 @@ npm run setup # 自动生成密钥并创建管理员账户
## 项目特定注意事项 ## 项目特定注意事项
### Redis 数据结构 ### Redis 数据结构
- **API Keys**: `api_key:{id}` (详细信息) + `api_key_hash:{hash}` (快速查找) - **API Keys**: `api_key:{id}` (详细信息) + `api_key_hash:{hash}` (快速查找)
- **Claude 账户**: `claude_account:{id}` (加密的 OAuth 数据) - **Claude 账户**: `claude_account:{id}` (加密的 OAuth 数据)
- **管理员**: `admin:{id}` + `admin_username:{username}` (用户名映射) - **管理员**: `admin:{id}` + `admin_username:{username}` (用户名映射)
@@ -203,12 +243,14 @@ npm run setup # 自动生成密钥并创建管理员账户
- **系统信息**: `system_info` (系统状态缓存) - **系统信息**: `system_info` (系统状态缓存)
### 流式响应处理 ### 流式响应处理
- 支持 SSE (Server-Sent Events) 流式传输 - 支持 SSE (Server-Sent Events) 流式传输
- 自动从流中解析 usage 数据并记录 - 自动从流中解析 usage 数据并记录
- 客户端断开时通过 AbortController 清理资源 - 客户端断开时通过 AbortController 清理资源
- 错误时发送适当的 SSE 错误事件 - 错误时发送适当的 SSE 错误事件
### CLI 工具使用示例 ### CLI 工具使用示例
```bash ```bash
# 创建新的 API Key # 创建新的 API Key
npm run cli keys create -- --name "MyApp" --limit 1000 npm run cli keys create -- --name "MyApp" --limit 1000
@@ -224,8 +266,10 @@ npm run cli accounts refresh <accountId>
npm run cli admin create -- --username admin2 npm run cli admin create -- --username admin2
npm run cli admin reset-password -- --username admin npm run cli admin reset-password -- --username admin
``` ```
# important-instruction-reminders # important-instruction-reminders
Do what has been asked; nothing more, nothing less. Do what has been asked; nothing more, nothing less.
NEVER create files unless they're absolutely necessary for achieving your goal. NEVER create files unless they're absolutely necessary for achieving your goal.
ALWAYS prefer editing an existing file to creating a new one. ALWAYS prefer editing an existing file to creating a new one.
NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User. NEVER proactively create documentation files (\*.md) or README files. Only create documentation files if explicitly requested by the User.

View File

@@ -32,16 +32,6 @@
📖 **免责声明**: 本项目仅供技术学习和研究使用,作者不对因使用本项目导致的账户封禁、服务中断或其他损失承担任何责任。 📖 **免责声明**: 本项目仅供技术学习和研究使用,作者不对因使用本项目导致的账户封禁、服务中断或其他损失承担任何责任。
---
> 💡 **感谢 [@vista8](https://x.com/vista8) 的推荐!**
>
> 如果你对Vibe coding感兴趣推荐关注
>
> - 🐦 **X**: [@vista8](https://x.com/vista8) - 分享前沿技术动态
> - 📱 **公众号**: 向阳乔木推荐看
---
## 🤔 这个项目适合你吗? ## 🤔 这个项目适合你吗?
@@ -321,20 +311,7 @@ npm run service:status
# 拉取镜像(支持 amd64 和 arm64 # 拉取镜像(支持 amd64 和 arm64
docker pull weishaw/claude-relay-service:latest docker pull weishaw/claude-relay-service:latest
# 使用 docker run 运行(注意设置必需的环境变量) # 使用 docker-compose
docker run -d \
--name claude-relay \
-p 3000:3000 \
-v $(pwd)/data:/app/data \
-v $(pwd)/logs:/app/logs \
-e JWT_SECRET=your-random-secret-key-at-least-32-chars \
-e ENCRYPTION_KEY=your-32-character-encryption-key \
-e REDIS_HOST=redis \
-e ADMIN_USERNAME=my_admin \
-e ADMIN_PASSWORD=my_secure_password \
weishaw/claude-relay-service:latest
# 或使用 docker-compose
# 创建 .env 文件用于 docker-compose 的环境变量: # 创建 .env 文件用于 docker-compose 的环境变量:
cat > .env << 'EOF' cat > .env << 'EOF'
# 必填:安全密钥(请修改为随机值) # 必填:安全密钥(请修改为随机值)

View File

@@ -1 +1 @@
1.1.115 1.1.120

View File

@@ -57,7 +57,9 @@ const config = {
// 🌐 代理配置 // 🌐 代理配置
proxy: { proxy: {
timeout: parseInt(process.env.DEFAULT_PROXY_TIMEOUT) || 30000, timeout: parseInt(process.env.DEFAULT_PROXY_TIMEOUT) || 30000,
maxRetries: parseInt(process.env.MAX_PROXY_RETRIES) || 3 maxRetries: parseInt(process.env.MAX_PROXY_RETRIES) || 3,
// IP协议族配置true=IPv4, false=IPv6, 默认IPv4兼容性更好
useIPv4: process.env.PROXY_USE_IPV4 !== 'false' // 默认 true只有明确设置为 'false' 才使用 IPv6
}, },
// 📈 使用限制 // 📈 使用限制

View File

@@ -12,6 +12,9 @@ services:
ports: ports:
# 绑定地址:生产环境建议使用反向代理,设置 BIND_HOST=127.0.0.1 # 绑定地址:生产环境建议使用反向代理,设置 BIND_HOST=127.0.0.1
- "${BIND_HOST:-0.0.0.0}:${PORT:-3000}:3000" - "${BIND_HOST:-0.0.0.0}:${PORT:-3000}:3000"
volumes:
- ./logs:/app/logs
- ./data:/app/data
environment: environment:
# 🌐 服务器配置 # 🌐 服务器配置
- NODE_ENV=production - NODE_ENV=production
@@ -56,7 +59,6 @@ services:
- CLEANUP_INTERVAL=${CLEANUP_INTERVAL:-3600000} - CLEANUP_INTERVAL=${CLEANUP_INTERVAL:-3600000}
- TOKEN_USAGE_RETENTION=${TOKEN_USAGE_RETENTION:-2592000000} - TOKEN_USAGE_RETENTION=${TOKEN_USAGE_RETENTION:-2592000000}
- HEALTH_CHECK_INTERVAL=${HEALTH_CHECK_INTERVAL:-60000} - HEALTH_CHECK_INTERVAL=${HEALTH_CHECK_INTERVAL:-60000}
- SYSTEM_TIMEZONE=${SYSTEM_TIMEZONE:-Asia/Shanghai}
- TIMEZONE_OFFSET=${TIMEZONE_OFFSET:-8} - TIMEZONE_OFFSET=${TIMEZONE_OFFSET:-8}
# 🎨 Web 界面配置 # 🎨 Web 界面配置
@@ -68,9 +70,6 @@ services:
- DEBUG=${DEBUG:-false} - DEBUG=${DEBUG:-false}
- ENABLE_CORS=${ENABLE_CORS:-true} - ENABLE_CORS=${ENABLE_CORS:-true}
- TRUST_PROXY=${TRUST_PROXY:-true} - TRUST_PROXY=${TRUST_PROXY:-true}
volumes:
- ./logs:/app/logs
- ./data:/app/data
depends_on: depends_on:
- redis - redis
networks: networks:

View File

@@ -79,7 +79,7 @@ async function testApiResponse() {
console.log('\n\n📊 验证结果:') console.log('\n\n📊 验证结果:')
// 检查 platform 字段 // 检查 platform 字段
const claudeWithPlatform = claudeAccounts.filter((a) => a.platform === 'claude-oauth') const claudeWithPlatform = claudeAccounts.filter((a) => a.platform === 'claude')
const consoleWithPlatform = consoleAccounts.filter((a) => a.platform === 'claude-console') const consoleWithPlatform = consoleAccounts.filter((a) => a.platform === 'claude-console')
if (claudeWithPlatform.length === claudeAccounts.length) { if (claudeWithPlatform.length === claudeAccounts.length) {

View File

@@ -22,6 +22,7 @@ const openaiGeminiRoutes = require('./routes/openaiGeminiRoutes')
const openaiClaudeRoutes = require('./routes/openaiClaudeRoutes') const openaiClaudeRoutes = require('./routes/openaiClaudeRoutes')
const openaiRoutes = require('./routes/openaiRoutes') const openaiRoutes = require('./routes/openaiRoutes')
const userRoutes = require('./routes/userRoutes') const userRoutes = require('./routes/userRoutes')
const azureOpenaiRoutes = require('./routes/azureOpenaiRoutes')
const webhookRoutes = require('./routes/webhook') const webhookRoutes = require('./routes/webhook')
// Import middleware // Import middleware
@@ -243,6 +244,7 @@ class Application {
this.app.use('/openai/gemini', openaiGeminiRoutes) this.app.use('/openai/gemini', openaiGeminiRoutes)
this.app.use('/openai/claude', openaiClaudeRoutes) this.app.use('/openai/claude', openaiClaudeRoutes)
this.app.use('/openai', openaiRoutes) this.app.use('/openai', openaiRoutes)
this.app.use('/azure', azureOpenaiRoutes)
this.app.use('/admin/webhook', webhookRoutes) this.app.use('/admin/webhook', webhookRoutes)
// 🏠 根路径重定向到新版管理界面 // 🏠 根路径重定向到新版管理界面

View File

@@ -5,6 +5,7 @@ const claudeConsoleAccountService = require('../services/claudeConsoleAccountSer
const bedrockAccountService = require('../services/bedrockAccountService') const bedrockAccountService = require('../services/bedrockAccountService')
const geminiAccountService = require('../services/geminiAccountService') const geminiAccountService = require('../services/geminiAccountService')
const openaiAccountService = require('../services/openaiAccountService') const openaiAccountService = require('../services/openaiAccountService')
const azureOpenaiAccountService = require('../services/azureOpenaiAccountService')
const accountGroupService = require('../services/accountGroupService') const accountGroupService = require('../services/accountGroupService')
const redis = require('../models/redis') const redis = require('../models/redis')
const { authenticateAdmin } = require('../middleware/auth') const { authenticateAdmin } = require('../middleware/auth')
@@ -13,13 +14,13 @@ const oauthHelper = require('../utils/oauthHelper')
const CostCalculator = require('../utils/costCalculator') const CostCalculator = require('../utils/costCalculator')
const pricingService = require('../services/pricingService') const pricingService = require('../services/pricingService')
const claudeCodeHeadersService = require('../services/claudeCodeHeadersService') const claudeCodeHeadersService = require('../services/claudeCodeHeadersService')
const webhookNotifier = require('../utils/webhookNotifier')
const axios = require('axios') const axios = require('axios')
const crypto = require('crypto') const crypto = require('crypto')
const fs = require('fs') const fs = require('fs')
const path = require('path') const path = require('path')
const config = require('../../config/config') const config = require('../../config/config')
const { SocksProxyAgent } = require('socks-proxy-agent') const ProxyHelper = require('../utils/proxyHelper')
const { HttpsProxyAgent } = require('https-proxy-agent')
const router = express.Router() const router = express.Router()
@@ -621,6 +622,170 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => {
} }
}) })
// 批量编辑API Keys
router.put('/api-keys/batch', authenticateAdmin, async (req, res) => {
try {
const { keyIds, updates } = req.body
if (!keyIds || !Array.isArray(keyIds) || keyIds.length === 0) {
return res.status(400).json({
error: 'Invalid input',
message: 'keyIds must be a non-empty array'
})
}
if (!updates || typeof updates !== 'object') {
return res.status(400).json({
error: 'Invalid input',
message: 'updates must be an object'
})
}
logger.info(
`🔄 Admin batch editing ${keyIds.length} API keys with updates: ${JSON.stringify(updates)}`
)
logger.info(`🔍 Debug: keyIds received: ${JSON.stringify(keyIds)}`)
const results = {
successCount: 0,
failedCount: 0,
errors: []
}
// 处理每个API Key
for (const keyId of keyIds) {
try {
// 获取当前API Key信息
const currentKey = await redis.getApiKey(keyId)
if (!currentKey || Object.keys(currentKey).length === 0) {
results.failedCount++
results.errors.push(`API key ${keyId} not found`)
continue
}
// 构建最终更新数据
const finalUpdates = {}
// 处理普通字段
if (updates.name) {
finalUpdates.name = updates.name
}
if (updates.tokenLimit !== undefined) {
finalUpdates.tokenLimit = updates.tokenLimit
}
if (updates.concurrencyLimit !== undefined) {
finalUpdates.concurrencyLimit = updates.concurrencyLimit
}
if (updates.rateLimitWindow !== undefined) {
finalUpdates.rateLimitWindow = updates.rateLimitWindow
}
if (updates.rateLimitRequests !== undefined) {
finalUpdates.rateLimitRequests = updates.rateLimitRequests
}
if (updates.dailyCostLimit !== undefined) {
finalUpdates.dailyCostLimit = updates.dailyCostLimit
}
if (updates.permissions !== undefined) {
finalUpdates.permissions = updates.permissions
}
if (updates.isActive !== undefined) {
finalUpdates.isActive = updates.isActive
}
if (updates.monthlyLimit !== undefined) {
finalUpdates.monthlyLimit = updates.monthlyLimit
}
if (updates.priority !== undefined) {
finalUpdates.priority = updates.priority
}
if (updates.enabled !== undefined) {
finalUpdates.enabled = updates.enabled
}
// 处理账户绑定
if (updates.claudeAccountId !== undefined) {
finalUpdates.claudeAccountId = updates.claudeAccountId
}
if (updates.claudeConsoleAccountId !== undefined) {
finalUpdates.claudeConsoleAccountId = updates.claudeConsoleAccountId
}
if (updates.geminiAccountId !== undefined) {
finalUpdates.geminiAccountId = updates.geminiAccountId
}
if (updates.openaiAccountId !== undefined) {
finalUpdates.openaiAccountId = updates.openaiAccountId
}
if (updates.bedrockAccountId !== undefined) {
finalUpdates.bedrockAccountId = updates.bedrockAccountId
}
// 处理标签操作
if (updates.tags !== undefined) {
if (updates.tagOperation) {
const currentTags = currentKey.tags ? JSON.parse(currentKey.tags) : []
const operationTags = updates.tags
switch (updates.tagOperation) {
case 'replace': {
finalUpdates.tags = operationTags
break
}
case 'add': {
const newTags = [...currentTags]
operationTags.forEach((tag) => {
if (!newTags.includes(tag)) {
newTags.push(tag)
}
})
finalUpdates.tags = newTags
break
}
case 'remove': {
finalUpdates.tags = currentTags.filter((tag) => !operationTags.includes(tag))
break
}
}
} else {
// 如果没有指定操作类型,默认为替换
finalUpdates.tags = updates.tags
}
}
// 执行更新
await apiKeyService.updateApiKey(keyId, finalUpdates)
results.successCount++
logger.success(`✅ Batch edit: API key ${keyId} updated successfully`)
} catch (error) {
results.failedCount++
results.errors.push(`Failed to update key ${keyId}: ${error.message}`)
logger.error(`❌ Batch edit failed for key ${keyId}:`, error)
}
}
// 记录批量编辑结果
if (results.successCount > 0) {
logger.success(
`🎉 Batch edit completed: ${results.successCount} successful, ${results.failedCount} failed`
)
} else {
logger.warn(
`⚠️ Batch edit completed with no successful updates: ${results.failedCount} failed`
)
}
return res.json({
success: true,
message: `批量编辑完成`,
data: results
})
} catch (error) {
logger.error('❌ Failed to batch edit API keys:', error)
return res.status(500).json({
error: 'Batch edit failed',
message: error.message
})
}
})
// 更新API Key // 更新API Key
router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
try { try {
@@ -799,7 +964,105 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
} }
}) })
// 删除API Key // 批量删除API Keys必须在 :keyId 路由之前定义)
router.delete('/api-keys/batch', authenticateAdmin, async (req, res) => {
try {
const { keyIds } = req.body
// 调试信息
logger.info(`🐛 Batch delete request body: ${JSON.stringify(req.body)}`)
logger.info(`🐛 keyIds type: ${typeof keyIds}, value: ${JSON.stringify(keyIds)}`)
// 参数验证
if (!keyIds || !Array.isArray(keyIds) || keyIds.length === 0) {
logger.warn(
`🚨 Invalid keyIds: ${JSON.stringify({ keyIds, type: typeof keyIds, isArray: Array.isArray(keyIds) })}`
)
return res.status(400).json({
error: 'Invalid request',
message: 'keyIds 必须是一个非空数组'
})
}
if (keyIds.length > 100) {
return res.status(400).json({
error: 'Too many keys',
message: '每次最多只能删除100个API Keys'
})
}
// 验证keyIds格式
const invalidKeys = keyIds.filter((id) => !id || typeof id !== 'string')
if (invalidKeys.length > 0) {
return res.status(400).json({
error: 'Invalid key IDs',
message: '包含无效的API Key ID'
})
}
logger.info(
`🗑️ Admin attempting batch delete of ${keyIds.length} API keys: ${JSON.stringify(keyIds)}`
)
const results = {
successCount: 0,
failedCount: 0,
errors: []
}
// 逐个删除,记录成功和失败情况
for (const keyId of keyIds) {
try {
// 检查API Key是否存在
const apiKey = await redis.getApiKey(keyId)
if (!apiKey || Object.keys(apiKey).length === 0) {
results.failedCount++
results.errors.push({ keyId, error: 'API Key 不存在' })
continue
}
// 执行删除
await apiKeyService.deleteApiKey(keyId)
results.successCount++
logger.success(`✅ Batch delete: API key ${keyId} deleted successfully`)
} catch (error) {
results.failedCount++
results.errors.push({
keyId,
error: error.message || '删除失败'
})
logger.error(`❌ Batch delete failed for key ${keyId}:`, error)
}
}
// 记录批量删除结果
if (results.successCount > 0) {
logger.success(
`🎉 Batch delete completed: ${results.successCount} successful, ${results.failedCount} failed`
)
} else {
logger.warn(
`⚠️ Batch delete completed with no successful deletions: ${results.failedCount} failed`
)
}
return res.json({
success: true,
message: `批量删除完成`,
data: results
})
} catch (error) {
logger.error('❌ Failed to batch delete API keys:', error)
return res.status(500).json({
error: 'Batch delete failed',
message: error.message
})
}
})
// 删除单个API Key必须在批量删除路由之后定义
router.delete('/api-keys/:keyId', authenticateAdmin, async (req, res) => { router.delete('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
try { try {
const { keyId } = req.params const { keyId } = req.params
@@ -1268,6 +1531,7 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
claudeAiOauth, claudeAiOauth,
proxy, proxy,
accountType, accountType,
platform = 'claude',
priority, priority,
groupId groupId
} = req.body } = req.body
@@ -1305,6 +1569,7 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
claudeAiOauth, claudeAiOauth,
proxy, proxy,
accountType: accountType || 'shared', // 默认为共享类型 accountType: accountType || 'shared', // 默认为共享类型
platform,
priority: priority || 50 // 默认优先级为50 priority: priority || 50 // 默认优先级为50
}) })
@@ -1498,6 +1763,19 @@ router.put(
const newSchedulable = !account.schedulable const newSchedulable = !account.schedulable
await claudeAccountService.updateAccount(accountId, { schedulable: newSchedulable }) await claudeAccountService.updateAccount(accountId, { schedulable: newSchedulable })
// 如果账号被禁用发送webhook通知
if (!newSchedulable) {
await webhookNotifier.sendAccountAnomalyNotification({
accountId: account.id,
accountName: account.name || account.claudeAiOauth?.email || 'Claude Account',
platform: 'claude-oauth',
status: 'disabled',
errorCode: 'CLAUDE_OAUTH_MANUALLY_DISABLED',
reason: '账号已被管理员手动禁用调度',
timestamp: new Date().toISOString()
})
}
logger.success( logger.success(
`🔄 Admin toggled Claude account schedulable status: ${accountId} -> ${newSchedulable ? 'schedulable' : 'not schedulable'}` `🔄 Admin toggled Claude account schedulable status: ${accountId} -> ${newSchedulable ? 'schedulable' : 'not schedulable'}`
) )
@@ -1768,6 +2046,19 @@ router.put(
const newSchedulable = !account.schedulable const newSchedulable = !account.schedulable
await claudeConsoleAccountService.updateAccount(accountId, { schedulable: newSchedulable }) await claudeConsoleAccountService.updateAccount(accountId, { schedulable: newSchedulable })
// 如果账号被禁用发送webhook通知
if (!newSchedulable) {
await webhookNotifier.sendAccountAnomalyNotification({
accountId: account.id,
accountName: account.name || 'Claude Console Account',
platform: 'claude-console',
status: 'disabled',
errorCode: 'CLAUDE_CONSOLE_MANUALLY_DISABLED',
reason: '账号已被管理员手动禁用调度',
timestamp: new Date().toISOString()
})
}
logger.success( logger.success(
`🔄 Admin toggled Claude Console account schedulable status: ${accountId} -> ${newSchedulable ? 'schedulable' : 'not schedulable'}` `🔄 Admin toggled Claude Console account schedulable status: ${accountId} -> ${newSchedulable ? 'schedulable' : 'not schedulable'}`
) )
@@ -2042,6 +2333,19 @@ router.put(
.json({ error: 'Failed to toggle schedulable status', message: updateResult.error }) .json({ error: 'Failed to toggle schedulable status', message: updateResult.error })
} }
// 如果账号被禁用发送webhook通知
if (!newSchedulable) {
await webhookNotifier.sendAccountAnomalyNotification({
accountId: accountResult.data.id,
accountName: accountResult.data.name || 'Bedrock Account',
platform: 'bedrock',
status: 'disabled',
errorCode: 'BEDROCK_MANUALLY_DISABLED',
reason: '账号已被管理员手动禁用调度',
timestamp: new Date().toISOString()
})
}
logger.success( logger.success(
`🔄 Admin toggled Bedrock account schedulable status: ${accountId} -> ${newSchedulable ? 'schedulable' : 'not schedulable'}` `🔄 Admin toggled Bedrock account schedulable status: ${accountId} -> ${newSchedulable ? 'schedulable' : 'not schedulable'}`
) )
@@ -2079,7 +2383,7 @@ router.post('/bedrock-accounts/:accountId/test', authenticateAdmin, async (req,
// 生成 Gemini OAuth 授权 URL // 生成 Gemini OAuth 授权 URL
router.post('/gemini-accounts/generate-auth-url', authenticateAdmin, async (req, res) => { router.post('/gemini-accounts/generate-auth-url', authenticateAdmin, async (req, res) => {
try { try {
const { state } = req.body const { state, proxy } = req.body // 接收代理配置
// 使用新的 codeassist.google.com 回调地址 // 使用新的 codeassist.google.com 回调地址
const redirectUri = 'https://codeassist.google.com/authcode' const redirectUri = 'https://codeassist.google.com/authcode'
@@ -2093,13 +2397,14 @@ router.post('/gemini-accounts/generate-auth-url', authenticateAdmin, async (req,
redirectUri: finalRedirectUri redirectUri: finalRedirectUri
} = await geminiAccountService.generateAuthUrl(state, redirectUri) } = await geminiAccountService.generateAuthUrl(state, redirectUri)
// 创建 OAuth 会话,包含 codeVerifier // 创建 OAuth 会话,包含 codeVerifier 和代理配置
const sessionId = authState const sessionId = authState
await redis.setOAuthSession(sessionId, { await redis.setOAuthSession(sessionId, {
state: authState, state: authState,
type: 'gemini', type: 'gemini',
redirectUri: finalRedirectUri, redirectUri: finalRedirectUri,
codeVerifier, // 保存 PKCE code verifier codeVerifier, // 保存 PKCE code verifier
proxy: proxy || null, // 保存代理配置
createdAt: new Date().toISOString() createdAt: new Date().toISOString()
}) })
@@ -2143,7 +2448,7 @@ router.post('/gemini-accounts/poll-auth-status', authenticateAdmin, async (req,
// 交换 Gemini 授权码 // 交换 Gemini 授权码
router.post('/gemini-accounts/exchange-code', authenticateAdmin, async (req, res) => { router.post('/gemini-accounts/exchange-code', authenticateAdmin, async (req, res) => {
try { try {
const { code, sessionId } = req.body const { code, sessionId, proxy: requestProxy } = req.body
if (!code) { if (!code) {
return res.status(400).json({ error: 'Authorization code is required' }) return res.status(400).json({ error: 'Authorization code is required' })
@@ -2151,21 +2456,40 @@ router.post('/gemini-accounts/exchange-code', authenticateAdmin, async (req, res
let redirectUri = 'https://codeassist.google.com/authcode' let redirectUri = 'https://codeassist.google.com/authcode'
let codeVerifier = null let codeVerifier = null
let proxyConfig = null
// 如果提供了 sessionId从 OAuth 会话中获取信息 // 如果提供了 sessionId从 OAuth 会话中获取信息
if (sessionId) { if (sessionId) {
const sessionData = await redis.getOAuthSession(sessionId) const sessionData = await redis.getOAuthSession(sessionId)
if (sessionData) { if (sessionData) {
const { redirectUri: sessionRedirectUri, codeVerifier: sessionCodeVerifier } = sessionData const {
redirectUri: sessionRedirectUri,
codeVerifier: sessionCodeVerifier,
proxy
} = sessionData
redirectUri = sessionRedirectUri || redirectUri redirectUri = sessionRedirectUri || redirectUri
codeVerifier = sessionCodeVerifier codeVerifier = sessionCodeVerifier
proxyConfig = proxy // 获取代理配置
logger.info( logger.info(
`Using session redirect_uri: ${redirectUri}, has codeVerifier: ${!!codeVerifier}` `Using session redirect_uri: ${redirectUri}, has codeVerifier: ${!!codeVerifier}, has proxy from session: ${!!proxyConfig}`
) )
} }
} }
const tokens = await geminiAccountService.exchangeCodeForTokens(code, redirectUri, codeVerifier) // 如果请求体中直接提供了代理配置,优先使用它
if (requestProxy) {
proxyConfig = requestProxy
logger.info(
`Using proxy from request body: ${proxyConfig ? JSON.stringify(proxyConfig) : 'none'}`
)
}
const tokens = await geminiAccountService.exchangeCodeForTokens(
code,
redirectUri,
codeVerifier,
proxyConfig // 传递代理配置
)
// 清理 OAuth 会话 // 清理 OAuth 会话
if (sessionId) { if (sessionId) {
@@ -2393,6 +2717,19 @@ router.put(
const updatedAccount = await geminiAccountService.getAccount(accountId) const updatedAccount = await geminiAccountService.getAccount(accountId)
const actualSchedulable = updatedAccount ? updatedAccount.schedulable : newSchedulable const actualSchedulable = updatedAccount ? updatedAccount.schedulable : newSchedulable
// 如果账号被禁用发送webhook通知
if (!actualSchedulable) {
await webhookNotifier.sendAccountAnomalyNotification({
accountId: account.id,
accountName: account.accountName || 'Gemini Account',
platform: 'gemini',
status: 'disabled',
errorCode: 'GEMINI_MANUALLY_DISABLED',
reason: '账号已被管理员手动禁用调度',
timestamp: new Date().toISOString()
})
}
logger.success( logger.success(
`🔄 Admin toggled Gemini account schedulable status: ${accountId} -> ${actualSchedulable ? 'schedulable' : 'not schedulable'}` `🔄 Admin toggled Gemini account schedulable status: ${accountId} -> ${actualSchedulable ? 'schedulable' : 'not schedulable'}`
) )
@@ -4575,19 +4912,10 @@ router.post('/openai-accounts/exchange-code', authenticateAdmin, async (req, res
} }
} }
if (sessionData.proxy) { // 配置代理(如果有)
const { type, host, port, username, password } = sessionData.proxy const proxyAgent = ProxyHelper.createProxyAgent(sessionData.proxy)
if (type === 'socks5') { if (proxyAgent) {
// SOCKS5 代理 axiosConfig.httpsAgent = proxyAgent
const auth = username && password ? `${username}:${password}@` : ''
const socksUrl = `socks5://${auth}${host}:${port}`
axiosConfig.httpsAgent = new SocksProxyAgent(socksUrl)
} else if (type === 'http' || type === 'https') {
// HTTP/HTTPS 代理
const auth = username && password ? `${username}:${password}@` : ''
const proxyUrl = `${type}://${auth}${host}:${port}`
axiosConfig.httpsAgent = new HttpsProxyAgent(proxyUrl)
}
} }
// 交换 authorization code 获取 tokens // 交换 authorization code 获取 tokens
@@ -4963,6 +5291,23 @@ router.put(
const result = await openaiAccountService.toggleSchedulable(accountId) const result = await openaiAccountService.toggleSchedulable(accountId)
// 如果账号被禁用发送webhook通知
if (!result.schedulable) {
// 获取账号信息
const account = await redis.getOpenAiAccount(accountId)
if (account) {
await webhookNotifier.sendAccountAnomalyNotification({
accountId: account.id,
accountName: account.name || 'OpenAI Account',
platform: 'openai',
status: 'disabled',
errorCode: 'OPENAI_MANUALLY_DISABLED',
reason: '账号已被管理员手动禁用调度',
timestamp: new Date().toISOString()
})
}
}
return res.json({ return res.json({
success: result.success, success: result.success,
schedulable: result.schedulable, schedulable: result.schedulable,
@@ -4979,4 +5324,308 @@ router.put(
} }
) )
// 🌐 Azure OpenAI 账户管理
// 获取所有 Azure OpenAI 账户
router.get('/azure-openai-accounts', authenticateAdmin, async (req, res) => {
try {
const accounts = await azureOpenaiAccountService.getAllAccounts()
res.json({
success: true,
data: accounts
})
} catch (error) {
logger.error('Failed to fetch Azure OpenAI accounts:', error)
res.status(500).json({
success: false,
message: 'Failed to fetch accounts',
error: error.message
})
}
})
// 创建 Azure OpenAI 账户
router.post('/azure-openai-accounts', authenticateAdmin, async (req, res) => {
try {
const {
name,
description,
accountType,
azureEndpoint,
apiVersion,
deploymentName,
apiKey,
supportedModels,
proxy,
groupId,
priority,
isActive,
schedulable
} = req.body
// 验证必填字段
if (!name) {
return res.status(400).json({
success: false,
message: 'Account name is required'
})
}
if (!azureEndpoint) {
return res.status(400).json({
success: false,
message: 'Azure endpoint is required'
})
}
if (!apiKey) {
return res.status(400).json({
success: false,
message: 'API key is required'
})
}
if (!deploymentName) {
return res.status(400).json({
success: false,
message: 'Deployment name is required'
})
}
// 验证 Azure endpoint 格式
if (!azureEndpoint.match(/^https:\/\/[\w-]+\.openai\.azure\.com$/)) {
return res.status(400).json({
success: false,
message:
'Invalid Azure OpenAI endpoint format. Expected: https://your-resource.openai.azure.com'
})
}
// 测试连接
try {
const testUrl = `${azureEndpoint}/openai/deployments/${deploymentName}?api-version=${apiVersion || '2024-02-01'}`
await axios.get(testUrl, {
headers: {
'api-key': apiKey
},
timeout: 5000
})
} catch (testError) {
if (testError.response?.status === 404) {
logger.warn('Azure OpenAI deployment not found, but continuing with account creation')
} else if (testError.response?.status === 401) {
return res.status(400).json({
success: false,
message: 'Invalid API key or unauthorized access'
})
}
}
const account = await azureOpenaiAccountService.createAccount({
name,
description,
accountType: accountType || 'shared',
azureEndpoint,
apiVersion: apiVersion || '2024-02-01',
deploymentName,
apiKey,
supportedModels,
proxy,
groupId,
priority: priority || 50,
isActive: isActive !== false,
schedulable: schedulable !== false
})
res.json({
success: true,
data: account,
message: 'Azure OpenAI account created successfully'
})
} catch (error) {
logger.error('Failed to create Azure OpenAI account:', error)
res.status(500).json({
success: false,
message: 'Failed to create account',
error: error.message
})
}
})
// 更新 Azure OpenAI 账户
router.put('/azure-openai-accounts/:id', authenticateAdmin, async (req, res) => {
try {
const { id } = req.params
const updates = req.body
const account = await azureOpenaiAccountService.updateAccount(id, updates)
res.json({
success: true,
data: account,
message: 'Azure OpenAI account updated successfully'
})
} catch (error) {
logger.error('Failed to update Azure OpenAI account:', error)
res.status(500).json({
success: false,
message: 'Failed to update account',
error: error.message
})
}
})
// 删除 Azure OpenAI 账户
router.delete('/azure-openai-accounts/:id', authenticateAdmin, async (req, res) => {
try {
const { id } = req.params
await azureOpenaiAccountService.deleteAccount(id)
res.json({
success: true,
message: 'Azure OpenAI account deleted successfully'
})
} catch (error) {
logger.error('Failed to delete Azure OpenAI account:', error)
res.status(500).json({
success: false,
message: 'Failed to delete account',
error: error.message
})
}
})
// 切换 Azure OpenAI 账户状态
router.put('/azure-openai-accounts/:id/toggle', authenticateAdmin, async (req, res) => {
try {
const { id } = req.params
const account = await azureOpenaiAccountService.getAccount(id)
if (!account) {
return res.status(404).json({
success: false,
message: 'Account not found'
})
}
const newStatus = account.isActive === 'true' ? 'false' : 'true'
await azureOpenaiAccountService.updateAccount(id, { isActive: newStatus })
res.json({
success: true,
message: `Account ${newStatus === 'true' ? 'activated' : 'deactivated'} successfully`,
isActive: newStatus === 'true'
})
} catch (error) {
logger.error('Failed to toggle Azure OpenAI account status:', error)
res.status(500).json({
success: false,
message: 'Failed to toggle account status',
error: error.message
})
}
})
// 切换 Azure OpenAI 账户调度状态
router.put(
'/azure-openai-accounts/:accountId/toggle-schedulable',
authenticateAdmin,
async (req, res) => {
try {
const { accountId } = req.params
const result = await azureOpenaiAccountService.toggleSchedulable(accountId)
// 如果账号被禁用发送webhook通知
if (!result.schedulable) {
// 获取账号信息
const account = await azureOpenaiAccountService.getAccount(accountId)
if (account) {
await webhookNotifier.sendAccountAnomalyNotification({
accountId: account.id,
accountName: account.name || 'Azure OpenAI Account',
platform: 'azure-openai',
status: 'disabled',
errorCode: 'AZURE_OPENAI_MANUALLY_DISABLED',
reason: '账号已被管理员手动禁用调度',
timestamp: new Date().toISOString()
})
}
}
return res.json({
success: true,
schedulable: result.schedulable,
message: result.schedulable ? '已启用调度' : '已禁用调度'
})
} catch (error) {
logger.error('切换 Azure OpenAI 账户调度状态失败:', error)
return res.status(500).json({
success: false,
message: '切换调度状态失败',
error: error.message
})
}
}
)
// 健康检查单个 Azure OpenAI 账户
router.post('/azure-openai-accounts/:id/health-check', authenticateAdmin, async (req, res) => {
try {
const { id } = req.params
const healthResult = await azureOpenaiAccountService.healthCheckAccount(id)
res.json({
success: true,
data: healthResult
})
} catch (error) {
logger.error('Failed to perform health check:', error)
res.status(500).json({
success: false,
message: 'Failed to perform health check',
error: error.message
})
}
})
// 批量健康检查所有 Azure OpenAI 账户
router.post('/azure-openai-accounts/health-check-all', authenticateAdmin, async (req, res) => {
try {
const healthResults = await azureOpenaiAccountService.performHealthChecks()
res.json({
success: true,
data: healthResults
})
} catch (error) {
logger.error('Failed to perform batch health check:', error)
res.status(500).json({
success: false,
message: 'Failed to perform batch health check',
error: error.message
})
}
})
// 迁移 API Keys 以支持 Azure OpenAI
router.post('/migrate-api-keys-azure', authenticateAdmin, async (req, res) => {
try {
const migratedCount = await azureOpenaiAccountService.migrateApiKeysForAzureSupport()
res.json({
success: true,
message: `Successfully migrated ${migratedCount} API keys for Azure OpenAI support`
})
} catch (error) {
logger.error('Failed to migrate API keys:', error)
res.status(500).json({
success: false,
message: 'Failed to migrate API keys',
error: error.message
})
}
})
module.exports = router module.exports = router

View File

@@ -671,4 +671,103 @@ router.get('/v1/organizations/:org_id/usage', authenticateApiKey, async (req, re
} }
}) })
// 🔢 Token计数端点 - count_tokens beta API
router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) => {
try {
// 检查权限
if (
req.apiKey.permissions &&
req.apiKey.permissions !== 'all' &&
req.apiKey.permissions !== 'claude'
) {
return res.status(403).json({
error: {
type: 'permission_error',
message: 'This API key does not have permission to access Claude'
}
})
}
logger.info(`🔢 Processing token count request for key: ${req.apiKey.name}`)
// 生成会话哈希用于sticky会话
const sessionHash = sessionHelper.generateSessionHash(req.body)
// 选择可用的Claude账户
const requestedModel = req.body.model
const { accountId, accountType } = await unifiedClaudeScheduler.selectAccountForApiKey(
req.apiKey,
sessionHash,
requestedModel
)
let response
if (accountType === 'claude-official') {
// 使用官方Claude账号转发count_tokens请求
response = await claudeRelayService.relayRequest(
req.body,
req.apiKey,
req,
res,
req.headers,
{
skipUsageRecord: true, // 跳过usage记录这只是计数请求
customPath: '/v1/messages/count_tokens' // 指定count_tokens路径
}
)
} else if (accountType === 'claude-console') {
// 使用Console Claude账号转发count_tokens请求
response = await claudeConsoleRelayService.relayRequest(
req.body,
req.apiKey,
req,
res,
req.headers,
accountId,
{
skipUsageRecord: true, // 跳过usage记录这只是计数请求
customPath: '/v1/messages/count_tokens' // 指定count_tokens路径
}
)
} else {
// Bedrock不支持count_tokens
return res.status(501).json({
error: {
type: 'not_supported',
message: 'Token counting is not supported for Bedrock accounts'
}
})
}
// 直接返回响应不记录token使用量
res.status(response.statusCode)
// 设置响应头
const skipHeaders = ['content-encoding', 'transfer-encoding', 'content-length']
Object.keys(response.headers).forEach((key) => {
if (!skipHeaders.includes(key.toLowerCase())) {
res.setHeader(key, response.headers[key])
}
})
// 尝试解析并返回JSON响应
try {
const jsonData = JSON.parse(response.body)
res.json(jsonData)
} catch (parseError) {
res.send(response.body)
}
logger.info(`✅ Token count request completed for key: ${req.apiKey.name}`)
} catch (error) {
logger.error('❌ Token count error:', error)
res.status(500).json({
error: {
type: 'server_error',
message: 'Failed to count tokens'
}
})
}
})
module.exports = router module.exports = router

View File

@@ -0,0 +1,318 @@
const express = require('express')
const router = express.Router()
const logger = require('../utils/logger')
const { authenticateApiKey } = require('../middleware/auth')
const azureOpenaiAccountService = require('../services/azureOpenaiAccountService')
const azureOpenaiRelayService = require('../services/azureOpenaiRelayService')
const apiKeyService = require('../services/apiKeyService')
const crypto = require('crypto')
// 支持的模型列表 - 基于真实的 Azure OpenAI 模型
const ALLOWED_MODELS = {
CHAT_MODELS: [
'gpt-4',
'gpt-4-turbo',
'gpt-4o',
'gpt-4o-mini',
'gpt-35-turbo',
'gpt-35-turbo-16k'
],
EMBEDDING_MODELS: ['text-embedding-ada-002', 'text-embedding-3-small', 'text-embedding-3-large']
}
const ALL_ALLOWED_MODELS = [...ALLOWED_MODELS.CHAT_MODELS, ...ALLOWED_MODELS.EMBEDDING_MODELS]
// Azure OpenAI 稳定 API 版本
// const AZURE_API_VERSION = '2024-02-01' // 当前未使用,保留以备后用
// 原子使用统计报告器
class AtomicUsageReporter {
constructor() {
this.reportedUsage = new Set()
this.pendingReports = new Map()
}
async reportOnce(requestId, usageData, apiKeyId, modelToRecord, accountId) {
if (this.reportedUsage.has(requestId)) {
logger.debug(`Usage already reported for request: ${requestId}`)
return false
}
// 防止并发重复报告
if (this.pendingReports.has(requestId)) {
return this.pendingReports.get(requestId)
}
const reportPromise = this._performReport(
requestId,
usageData,
apiKeyId,
modelToRecord,
accountId
)
this.pendingReports.set(requestId, reportPromise)
try {
const result = await reportPromise
this.reportedUsage.add(requestId)
return result
} finally {
this.pendingReports.delete(requestId)
// 清理过期的已报告记录
setTimeout(() => this.reportedUsage.delete(requestId), 60 * 1000) // 1分钟后清理
}
}
async _performReport(requestId, usageData, apiKeyId, modelToRecord, accountId) {
try {
const inputTokens = usageData.prompt_tokens || usageData.input_tokens || 0
const outputTokens = usageData.completion_tokens || usageData.output_tokens || 0
const cacheCreateTokens =
usageData.prompt_tokens_details?.cache_creation_tokens ||
usageData.input_tokens_details?.cache_creation_tokens ||
0
const cacheReadTokens =
usageData.prompt_tokens_details?.cached_tokens ||
usageData.input_tokens_details?.cached_tokens ||
0
await apiKeyService.recordUsage(
apiKeyId,
inputTokens,
outputTokens,
cacheCreateTokens,
cacheReadTokens,
modelToRecord,
accountId
)
// 同步更新 Azure 账户的 lastUsedAt 和累计使用量
try {
const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens
if (accountId) {
await azureOpenaiAccountService.updateAccountUsage(accountId, totalTokens)
}
} catch (acctErr) {
logger.warn(`Failed to update Azure account usage for ${accountId}: ${acctErr.message}`)
}
logger.info(
`📊 Azure OpenAI Usage recorded for ${requestId}: ` +
`model=${modelToRecord}, ` +
`input=${inputTokens}, output=${outputTokens}, ` +
`cache_create=${cacheCreateTokens}, cache_read=${cacheReadTokens}`
)
return true
} catch (error) {
logger.error('Failed to report Azure OpenAI usage:', error)
return false
}
}
}
const usageReporter = new AtomicUsageReporter()
// 健康检查
router.get('/health', (req, res) => {
res.status(200).json({
status: 'healthy',
service: 'azure-openai-relay',
timestamp: new Date().toISOString()
})
})
// 获取可用模型列表(兼容 OpenAI API
router.get('/models', authenticateApiKey, async (req, res) => {
try {
const models = ALL_ALLOWED_MODELS.map((model) => ({
id: `azure/${model}`,
object: 'model',
created: Date.now(),
owned_by: 'azure-openai'
}))
res.json({
object: 'list',
data: models
})
} catch (error) {
logger.error('Error fetching Azure OpenAI models:', error)
res.status(500).json({ error: { message: 'Failed to fetch models' } })
}
})
// 处理聊天完成请求
router.post('/chat/completions', authenticateApiKey, async (req, res) => {
const requestId = `azure_req_${Date.now()}_${crypto.randomBytes(8).toString('hex')}`
const sessionId = req.sessionId || req.headers['x-session-id'] || null
logger.info(`🚀 Azure OpenAI Chat Request ${requestId}`, {
apiKeyId: req.apiKey?.id,
sessionId,
model: req.body.model,
stream: req.body.stream || false,
messages: req.body.messages?.length || 0
})
try {
// 获取绑定的 Azure OpenAI 账户
let account = null
if (req.apiKey?.azureOpenaiAccountId) {
account = await azureOpenaiAccountService.getAccount(req.apiKey.azureOpenaiAccountId)
if (!account) {
logger.warn(`Bound Azure OpenAI account not found: ${req.apiKey.azureOpenaiAccountId}`)
}
}
// 如果没有绑定账户或账户不可用,选择一个可用账户
if (!account || account.isActive !== 'true') {
account = await azureOpenaiAccountService.selectAvailableAccount(sessionId)
}
// 发送请求到 Azure OpenAI
const response = await azureOpenaiRelayService.handleAzureOpenAIRequest({
account,
requestBody: req.body,
headers: req.headers,
isStream: req.body.stream || false,
endpoint: 'chat/completions'
})
// 处理流式响应
if (req.body.stream) {
await azureOpenaiRelayService.handleStreamResponse(response, res, {
onEnd: async ({ usageData, actualModel }) => {
if (usageData) {
const modelToRecord = actualModel || req.body.model || 'unknown'
await usageReporter.reportOnce(
requestId,
usageData,
req.apiKey.id,
modelToRecord,
account.id
)
}
},
onError: (error) => {
logger.error(`Stream error for request ${requestId}:`, error)
}
})
} else {
// 处理非流式响应
const { usageData, actualModel } = azureOpenaiRelayService.handleNonStreamResponse(
response,
res
)
if (usageData) {
const modelToRecord = actualModel || req.body.model || 'unknown'
await usageReporter.reportOnce(
requestId,
usageData,
req.apiKey.id,
modelToRecord,
account.id
)
}
}
} catch (error) {
logger.error(`Azure OpenAI request failed ${requestId}:`, error)
if (!res.headersSent) {
const statusCode = error.response?.status || 500
const errorMessage =
error.response?.data?.error?.message || error.message || 'Internal server error'
res.status(statusCode).json({
error: {
message: errorMessage,
type: 'azure_openai_error',
code: error.code || 'unknown'
}
})
}
}
})
// 处理嵌入请求
router.post('/embeddings', authenticateApiKey, async (req, res) => {
const requestId = `azure_embed_${Date.now()}_${crypto.randomBytes(8).toString('hex')}`
const sessionId = req.sessionId || req.headers['x-session-id'] || null
logger.info(`🚀 Azure OpenAI Embeddings Request ${requestId}`, {
apiKeyId: req.apiKey?.id,
sessionId,
model: req.body.model,
input: Array.isArray(req.body.input) ? req.body.input.length : 1
})
try {
// 获取绑定的 Azure OpenAI 账户
let account = null
if (req.apiKey?.azureOpenaiAccountId) {
account = await azureOpenaiAccountService.getAccount(req.apiKey.azureOpenaiAccountId)
if (!account) {
logger.warn(`Bound Azure OpenAI account not found: ${req.apiKey.azureOpenaiAccountId}`)
}
}
// 如果没有绑定账户或账户不可用,选择一个可用账户
if (!account || account.isActive !== 'true') {
account = await azureOpenaiAccountService.selectAvailableAccount(sessionId)
}
// 发送请求到 Azure OpenAI
const response = await azureOpenaiRelayService.handleAzureOpenAIRequest({
account,
requestBody: req.body,
headers: req.headers,
isStream: false,
endpoint: 'embeddings'
})
// 处理响应
const { usageData, actualModel } = azureOpenaiRelayService.handleNonStreamResponse(
response,
res
)
if (usageData) {
const modelToRecord = actualModel || req.body.model || 'unknown'
await usageReporter.reportOnce(requestId, usageData, req.apiKey.id, modelToRecord, account.id)
}
} catch (error) {
logger.error(`Azure OpenAI embeddings request failed ${requestId}:`, error)
if (!res.headersSent) {
const statusCode = error.response?.status || 500
const errorMessage =
error.response?.data?.error?.message || error.message || 'Internal server error'
res.status(statusCode).json({
error: {
message: errorMessage,
type: 'azure_openai_error',
code: error.code || 'unknown'
}
})
}
}
})
// 获取使用统计
router.get('/usage', authenticateApiKey, async (req, res) => {
try {
const { start_date, end_date } = req.query
const usage = await apiKeyService.getUsageStats(req.apiKey.id, start_date, end_date)
res.json({
object: 'usage',
data: usage
})
} catch (error) {
logger.error('Error fetching Azure OpenAI usage:', error)
res.status(500).json({ error: { message: 'Failed to fetch usage data' } })
}
})
module.exports = router

View File

@@ -541,12 +541,24 @@ async function handleGenerateContent(req, res) {
}) })
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken) const client = await geminiAccountService.getOauthClient(accessToken, refreshToken)
// 解析账户的代理配置
let proxyConfig = null
if (account.proxy) {
try {
proxyConfig = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy
} catch (e) {
logger.warn('Failed to parse proxy configuration:', e)
}
}
const response = await geminiAccountService.generateContent( const response = await geminiAccountService.generateContent(
client, client,
{ model, request: actualRequestData }, { model, request: actualRequestData },
user_prompt_id, user_prompt_id,
account.projectId, // 始终使用账户配置的项目ID忽略请求中的project account.projectId, // 始终使用账户配置的项目ID忽略请求中的project
req.apiKey?.id // 使用 API Key ID 作为 session ID req.apiKey?.id, // 使用 API Key ID 作为 session ID
proxyConfig // 传递代理配置
) )
// 记录使用统计 // 记录使用统计
@@ -573,7 +585,16 @@ async function handleGenerateContent(req, res) {
res.json(response) res.json(response)
} catch (error) { } catch (error) {
const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal' const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal'
logger.error(`Error in generateContent endpoint (${version})`, { error: error.message }) // 打印详细的错误信息
logger.error(`Error in generateContent endpoint (${version})`, {
message: error.message,
status: error.response?.status,
statusText: error.response?.statusText,
responseData: error.response?.data,
requestUrl: error.config?.url,
requestMethod: error.config?.method,
stack: error.stack
})
res.status(500).json({ res.status(500).json({
error: { error: {
message: error.message || 'Internal server error', message: error.message || 'Internal server error',
@@ -654,13 +675,25 @@ async function handleStreamGenerateContent(req, res) {
}) })
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken) const client = await geminiAccountService.getOauthClient(accessToken, refreshToken)
// 解析账户的代理配置
let proxyConfig = null
if (account.proxy) {
try {
proxyConfig = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy
} catch (e) {
logger.warn('Failed to parse proxy configuration:', e)
}
}
const streamResponse = await geminiAccountService.generateContentStream( const streamResponse = await geminiAccountService.generateContentStream(
client, client,
{ model, request: actualRequestData }, { model, request: actualRequestData },
user_prompt_id, user_prompt_id,
account.projectId, // 始终使用账户配置的项目ID忽略请求中的project account.projectId, // 始终使用账户配置的项目ID忽略请求中的project
req.apiKey?.id, // 使用 API Key ID 作为 session ID req.apiKey?.id, // 使用 API Key ID 作为 session ID
abortController.signal // 传递中止信号 abortController.signal, // 传递中止信号
proxyConfig // 传递代理配置
) )
// 设置 SSE 响应头 // 设置 SSE 响应头
@@ -756,7 +789,16 @@ async function handleStreamGenerateContent(req, res) {
}) })
} catch (error) { } catch (error) {
const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal' const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal'
logger.error(`Error in streamGenerateContent endpoint (${version})`, { error: error.message }) // 打印详细的错误信息
logger.error(`Error in streamGenerateContent endpoint (${version})`, {
message: error.message,
status: error.response?.status,
statusText: error.response?.statusText,
responseData: error.response?.data,
requestUrl: error.config?.url,
requestMethod: error.config?.method,
stack: error.stack
})
if (!res.headersSent) { if (!res.headersSent) {
res.status(500).json({ res.status(500).json({

View File

@@ -8,30 +8,11 @@ const unifiedOpenAIScheduler = require('../services/unifiedOpenAIScheduler')
const openaiAccountService = require('../services/openaiAccountService') const openaiAccountService = require('../services/openaiAccountService')
const apiKeyService = require('../services/apiKeyService') const apiKeyService = require('../services/apiKeyService')
const crypto = require('crypto') const crypto = require('crypto')
const { SocksProxyAgent } = require('socks-proxy-agent') const ProxyHelper = require('../utils/proxyHelper')
const { HttpsProxyAgent } = require('https-proxy-agent')
// 创建代理 Agent // 创建代理 Agent(使用统一的代理工具)
function createProxyAgent(proxy) { function createProxyAgent(proxy) {
if (!proxy) { return ProxyHelper.createProxyAgent(proxy)
return null
}
try {
if (proxy.type === 'socks5') {
const auth = proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : ''
const socksUrl = `socks5://${auth}${proxy.host}:${proxy.port}`
return new SocksProxyAgent(socksUrl)
} else if (proxy.type === 'http' || proxy.type === 'https') {
const auth = proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : ''
const proxyUrl = `${proxy.type}://${auth}${proxy.host}:${proxy.port}`
return new HttpsProxyAgent(proxyUrl)
}
} catch (error) {
logger.warn('Failed to create proxy agent:', error)
}
return null
} }
// 使用统一调度器选择 OpenAI 账户 // 使用统一调度器选择 OpenAI 账户
@@ -80,7 +61,8 @@ async function getOpenAIAuthToken(apiKeyData, sessionId = null, requestedModel =
accessToken, accessToken,
accountId: result.accountId, accountId: result.accountId,
accountName: account.name, accountName: account.name,
proxy proxy,
account
} }
} catch (error) { } catch (error) {
logger.error('Failed to get OpenAI auth token:', error) logger.error('Failed to get OpenAI auth token:', error)
@@ -146,11 +128,13 @@ router.post('/responses', authenticateApiKey, async (req, res) => {
} }
// 使用调度器选择账户 // 使用调度器选择账户
const { accessToken, accountId, proxy } = await getOpenAIAuthToken( const {
apiKeyData, accessToken,
sessionId, accountId,
requestedModel accountName: _accountName,
) proxy,
account
} = await getOpenAIAuthToken(apiKeyData, sessionId, requestedModel)
// 基于白名单构造上游所需的请求头,确保键为小写且值受控 // 基于白名单构造上游所需的请求头,确保键为小写且值受控
const incoming = req.headers || {} const incoming = req.headers || {}
@@ -165,7 +149,7 @@ router.post('/responses', authenticateApiKey, async (req, res) => {
// 覆盖或新增必要头部 // 覆盖或新增必要头部
headers['authorization'] = `Bearer ${accessToken}` headers['authorization'] = `Bearer ${accessToken}`
headers['chatgpt-account-id'] = accountId headers['chatgpt-account-id'] = account.accountId || account.chatgptUserId || accountId
headers['host'] = 'chatgpt.com' headers['host'] = 'chatgpt.com'
headers['accept'] = isStream ? 'text/event-stream' : 'application/json' headers['accept'] = isStream ? 'text/event-stream' : 'application/json'
headers['content-type'] = 'application/json' headers['content-type'] = 'application/json'
@@ -184,7 +168,9 @@ router.post('/responses', authenticateApiKey, async (req, res) => {
// 如果有代理,添加代理配置 // 如果有代理,添加代理配置
if (proxyAgent) { if (proxyAgent) {
axiosConfig.httpsAgent = proxyAgent axiosConfig.httpsAgent = proxyAgent
logger.info('Using proxy for OpenAI request') logger.info(`🌐 Using proxy for OpenAI request: ${ProxyHelper.getProxyDescription(proxy)}`)
} else {
logger.debug('🌐 No proxy configured for OpenAI request')
} }
// 根据 stream 参数决定请求类型 // 根据 stream 参数决定请求类型

View File

@@ -1,18 +1,125 @@
const express = require('express') const express = require('express')
const router = express.Router() const router = express.Router()
const logger = require('../utils/logger') const logger = require('../utils/logger')
const webhookNotifier = require('../utils/webhookNotifier') const webhookService = require('../services/webhookService')
const webhookConfigService = require('../services/webhookConfigService')
const { authenticateAdmin } = require('../middleware/auth') const { authenticateAdmin } = require('../middleware/auth')
// 获取webhook配置
router.get('/config', authenticateAdmin, async (req, res) => {
try {
const config = await webhookConfigService.getConfig()
res.json({
success: true,
config
})
} catch (error) {
logger.error('获取webhook配置失败:', error)
res.status(500).json({
error: 'Internal server error',
message: '获取webhook配置失败'
})
}
})
// 保存webhook配置
router.post('/config', authenticateAdmin, async (req, res) => {
try {
const config = await webhookConfigService.saveConfig(req.body)
res.json({
success: true,
message: 'Webhook配置已保存',
config
})
} catch (error) {
logger.error('保存webhook配置失败:', error)
res.status(500).json({
error: 'Internal server error',
message: error.message || '保存webhook配置失败'
})
}
})
// 添加webhook平台
router.post('/platforms', authenticateAdmin, async (req, res) => {
try {
const platform = await webhookConfigService.addPlatform(req.body)
res.json({
success: true,
message: 'Webhook平台已添加',
platform
})
} catch (error) {
logger.error('添加webhook平台失败:', error)
res.status(500).json({
error: 'Internal server error',
message: error.message || '添加webhook平台失败'
})
}
})
// 更新webhook平台
router.put('/platforms/:id', authenticateAdmin, async (req, res) => {
try {
const platform = await webhookConfigService.updatePlatform(req.params.id, req.body)
res.json({
success: true,
message: 'Webhook平台已更新',
platform
})
} catch (error) {
logger.error('更新webhook平台失败:', error)
res.status(500).json({
error: 'Internal server error',
message: error.message || '更新webhook平台失败'
})
}
})
// 删除webhook平台
router.delete('/platforms/:id', authenticateAdmin, async (req, res) => {
try {
await webhookConfigService.deletePlatform(req.params.id)
res.json({
success: true,
message: 'Webhook平台已删除'
})
} catch (error) {
logger.error('删除webhook平台失败:', error)
res.status(500).json({
error: 'Internal server error',
message: error.message || '删除webhook平台失败'
})
}
})
// 切换webhook平台启用状态
router.post('/platforms/:id/toggle', authenticateAdmin, async (req, res) => {
try {
const platform = await webhookConfigService.togglePlatform(req.params.id)
res.json({
success: true,
message: `Webhook平台已${platform.enabled ? '启用' : '禁用'}`,
platform
})
} catch (error) {
logger.error('切换webhook平台状态失败:', error)
res.status(500).json({
error: 'Internal server error',
message: error.message || '切换webhook平台状态失败'
})
}
})
// 测试Webhook连通性 // 测试Webhook连通性
router.post('/test', authenticateAdmin, async (req, res) => { router.post('/test', authenticateAdmin, async (req, res) => {
try { try {
const { url } = req.body const { url, type = 'custom', secret, enableSign } = req.body
if (!url) { if (!url) {
return res.status(400).json({ return res.status(400).json({
error: 'Missing webhook URL', error: 'Missing webhook URL',
message: 'Please provide a webhook URL to test' message: '请提供webhook URL'
}) })
} }
@@ -22,99 +129,144 @@ router.post('/test', authenticateAdmin, async (req, res) => {
} catch (urlError) { } catch (urlError) {
return res.status(400).json({ return res.status(400).json({
error: 'Invalid URL format', error: 'Invalid URL format',
message: 'Please provide a valid webhook URL' message: '请提供有效的webhook URL'
}) })
} }
logger.info(`🧪 Testing webhook URL: ${url}`) logger.info(`🧪 测试webhook: ${type} - ${url}`)
const result = await webhookNotifier.testWebhook(url) // 创建临时平台配置
const platform = {
type,
url,
secret,
enableSign,
enabled: true,
timeout: 10000
}
const result = await webhookService.testWebhook(platform)
if (result.success) { if (result.success) {
logger.info(`✅ Webhook test successful for: ${url}`) logger.info(`✅ Webhook测试成功: ${url}`)
res.json({ res.json({
success: true, success: true,
message: 'Webhook test successful', message: 'Webhook测试成功',
url url
}) })
} else { } else {
logger.warn(`❌ Webhook test failed for: ${url} - ${result.error}`) logger.warn(`❌ Webhook测试失败: ${url} - ${result.error}`)
res.status(400).json({ res.status(400).json({
success: false, success: false,
message: 'Webhook test failed', message: 'Webhook测试失败',
url, url,
error: result.error error: result.error
}) })
} }
} catch (error) { } catch (error) {
logger.error('❌ Webhook test error:', error) logger.error('❌ Webhook测试错误:', error)
res.status(500).json({ res.status(500).json({
error: 'Internal server error', error: 'Internal server error',
message: 'Failed to test webhook' message: '测试webhook失败'
}) })
} }
}) })
// 手动触发账号异常通知(用于测试) // 手动触发测试通知
router.post('/test-notification', authenticateAdmin, async (req, res) => { router.post('/test-notification', authenticateAdmin, async (req, res) => {
try { try {
const { const {
type = 'test',
accountId = 'test-account-id', accountId = 'test-account-id',
accountName = 'Test Account', accountName = '测试账号',
platform = 'claude-oauth', platform = 'claude-oauth',
status = 'error', status = 'test',
errorCode = 'TEST_ERROR', errorCode = 'TEST_NOTIFICATION',
reason = 'Manual test notification' reason = '手动测试通知',
message = '这是一条测试通知消息,用于验证 Webhook 通知功能是否正常工作'
} = req.body } = req.body
logger.info(`🧪 Sending test notification for account: ${accountName}`) logger.info(`🧪 发送测试通知: ${type}`)
await webhookNotifier.sendAccountAnomalyNotification({ // 先检查webhook配置
const config = await webhookConfigService.getConfig()
logger.debug(
`Webhook配置: enabled=${config.enabled}, platforms=${config.platforms?.length || 0}`
)
if (!config.enabled) {
return res.status(400).json({
success: false,
message: 'Webhook通知未启用请先在设置中启用通知功能'
})
}
const enabledPlatforms = await webhookConfigService.getEnabledPlatforms()
logger.info(`找到 ${enabledPlatforms.length} 个启用的通知平台`)
if (enabledPlatforms.length === 0) {
return res.status(400).json({
success: false,
message: '没有启用的通知平台,请先添加并启用至少一个通知平台'
})
}
const testData = {
accountId, accountId,
accountName, accountName,
platform, platform,
status, status,
errorCode, errorCode,
reason reason,
}) message,
timestamp: new Date().toISOString()
}
logger.info(`✅ Test notification sent successfully`) const result = await webhookService.sendNotification(type, testData)
// 如果没有返回结果,说明可能是配置问题
if (!result) {
return res.status(400).json({
success: false,
message: 'Webhook服务未返回结果请检查配置和日志',
enabledPlatforms: enabledPlatforms.length
})
}
// 如果没有成功和失败的记录
if (result.succeeded === 0 && result.failed === 0) {
return res.status(400).json({
success: false,
message: '没有发送任何通知,请检查通知类型配置',
result,
enabledPlatforms: enabledPlatforms.length
})
}
if (result.failed > 0) {
logger.warn(`⚠️ 测试通知部分失败: ${result.succeeded}成功, ${result.failed}失败`)
return res.json({
success: true,
message: `测试通知部分成功: ${result.succeeded}个平台成功, ${result.failed}个平台失败`,
data: testData,
result
})
}
logger.info(`✅ 测试通知发送成功到 ${result.succeeded} 个平台`)
res.json({ res.json({
success: true, success: true,
message: 'Test notification sent successfully', message: `测试通知已成功发送到 ${result.succeeded} 个平台`,
data: { data: testData,
accountId, result
accountName,
platform,
status,
errorCode,
reason
}
}) })
} catch (error) { } catch (error) {
logger.error('❌ Failed to send test notification:', error) logger.error('❌ 发送测试通知失败:', error)
res.status(500).json({ res.status(500).json({
error: 'Internal server error', error: 'Internal server error',
message: 'Failed to send test notification' message: `发送测试通知失败: ${error.message}`
}) })
} }
}) })
// 获取Webhook配置信息
router.get('/config', authenticateAdmin, (req, res) => {
const config = require('../../config/config')
res.json({
success: true,
config: {
enabled: config.webhook?.enabled !== false,
urls: config.webhook?.urls || [],
timeout: config.webhook?.timeout || 10000,
retries: config.webhook?.retries || 3,
urlCount: (config.webhook?.urls || []).length
}
})
})
module.exports = router module.exports = router

View File

@@ -20,6 +20,7 @@ class ApiKeyService {
claudeConsoleAccountId = null, claudeConsoleAccountId = null,
geminiAccountId = null, geminiAccountId = null,
openaiAccountId = null, openaiAccountId = null,
azureOpenaiAccountId = null,
bedrockAccountId = null, // 添加 Bedrock 账号ID支持 bedrockAccountId = null, // 添加 Bedrock 账号ID支持
permissions = 'all', // 'claude', 'gemini', 'openai', 'all' permissions = 'all', // 'claude', 'gemini', 'openai', 'all'
isActive = true, isActive = true,
@@ -53,6 +54,7 @@ class ApiKeyService {
claudeConsoleAccountId: claudeConsoleAccountId || '', claudeConsoleAccountId: claudeConsoleAccountId || '',
geminiAccountId: geminiAccountId || '', geminiAccountId: geminiAccountId || '',
openaiAccountId: openaiAccountId || '', openaiAccountId: openaiAccountId || '',
azureOpenaiAccountId: azureOpenaiAccountId || '',
bedrockAccountId: bedrockAccountId || '', // 添加 Bedrock 账号ID bedrockAccountId: bedrockAccountId || '', // 添加 Bedrock 账号ID
permissions: permissions || 'all', permissions: permissions || 'all',
enableModelRestriction: String(enableModelRestriction), enableModelRestriction: String(enableModelRestriction),
@@ -88,6 +90,7 @@ class ApiKeyService {
claudeConsoleAccountId: keyData.claudeConsoleAccountId, claudeConsoleAccountId: keyData.claudeConsoleAccountId,
geminiAccountId: keyData.geminiAccountId, geminiAccountId: keyData.geminiAccountId,
openaiAccountId: keyData.openaiAccountId, openaiAccountId: keyData.openaiAccountId,
azureOpenaiAccountId: keyData.azureOpenaiAccountId,
bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID
permissions: keyData.permissions, permissions: keyData.permissions,
enableModelRestriction: keyData.enableModelRestriction === 'true', enableModelRestriction: keyData.enableModelRestriction === 'true',
@@ -190,6 +193,7 @@ class ApiKeyService {
claudeConsoleAccountId: keyData.claudeConsoleAccountId, claudeConsoleAccountId: keyData.claudeConsoleAccountId,
geminiAccountId: keyData.geminiAccountId, geminiAccountId: keyData.geminiAccountId,
openaiAccountId: keyData.openaiAccountId, openaiAccountId: keyData.openaiAccountId,
azureOpenaiAccountId: keyData.azureOpenaiAccountId,
bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID
permissions: keyData.permissions || 'all', permissions: keyData.permissions || 'all',
tokenLimit: parseInt(keyData.tokenLimit), tokenLimit: parseInt(keyData.tokenLimit),
@@ -337,6 +341,7 @@ class ApiKeyService {
'claudeConsoleAccountId', 'claudeConsoleAccountId',
'geminiAccountId', 'geminiAccountId',
'openaiAccountId', 'openaiAccountId',
'azureOpenaiAccountId',
'bedrockAccountId', // 添加 Bedrock 账号ID 'bedrockAccountId', // 添加 Bedrock 账号ID
'permissions', 'permissions',
'expiresAt', 'expiresAt',

View File

@@ -0,0 +1,479 @@
const redisClient = require('../models/redis')
const { v4: uuidv4 } = require('uuid')
const crypto = require('crypto')
const config = require('../../config/config')
const logger = require('../utils/logger')
// 加密相关常量
const ALGORITHM = 'aes-256-cbc'
const IV_LENGTH = 16
// 🚀 安全的加密密钥生成支持动态salt
const ENCRYPTION_SALT = config.security?.azureOpenaiSalt || 'azure-openai-account-default-salt'
class EncryptionKeyManager {
constructor() {
this.keyCache = new Map()
this.keyRotationInterval = 24 * 60 * 60 * 1000 // 24小时
}
getKey(version = 'current') {
const cached = this.keyCache.get(version)
if (cached && Date.now() - cached.timestamp < this.keyRotationInterval) {
return cached.key
}
// 生成新密钥
const key = crypto.scryptSync(config.security.encryptionKey, ENCRYPTION_SALT, 32)
this.keyCache.set(version, {
key,
timestamp: Date.now()
})
logger.debug('🔑 Azure OpenAI encryption key generated/refreshed')
return key
}
// 清理过期密钥
cleanup() {
const now = Date.now()
for (const [version, cached] of this.keyCache.entries()) {
if (now - cached.timestamp > this.keyRotationInterval) {
this.keyCache.delete(version)
}
}
}
}
const encryptionKeyManager = new EncryptionKeyManager()
// 定期清理过期密钥
setInterval(
() => {
encryptionKeyManager.cleanup()
},
60 * 60 * 1000
) // 每小时清理一次
// 生成加密密钥 - 使用安全的密钥管理器
function generateEncryptionKey() {
return encryptionKeyManager.getKey()
}
// Azure OpenAI 账户键前缀
const AZURE_OPENAI_ACCOUNT_KEY_PREFIX = 'azure_openai:account:'
const SHARED_AZURE_OPENAI_ACCOUNTS_KEY = 'shared_azure_openai_accounts'
const ACCOUNT_SESSION_MAPPING_PREFIX = 'azure_openai_session_account_mapping:'
// 加密函数
function encrypt(text) {
if (!text) {
return ''
}
const key = generateEncryptionKey()
const iv = crypto.randomBytes(IV_LENGTH)
const cipher = crypto.createCipheriv(ALGORITHM, key, iv)
let encrypted = cipher.update(text)
encrypted = Buffer.concat([encrypted, cipher.final()])
return `${iv.toString('hex')}:${encrypted.toString('hex')}`
}
// 解密函数 - 移除缓存以提高安全性
function decrypt(text) {
if (!text) {
return ''
}
try {
const key = generateEncryptionKey()
// IV 是固定长度的 32 个十六进制字符16 字节)
const ivHex = text.substring(0, 32)
const encryptedHex = text.substring(33) // 跳过冒号
if (ivHex.length !== 32 || !encryptedHex) {
throw new Error('Invalid encrypted text format')
}
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()])
const result = decrypted.toString()
return result
} catch (error) {
logger.error('Azure OpenAI decryption error:', error.message)
return ''
}
}
// 创建账户
async function createAccount(accountData) {
const accountId = uuidv4()
const now = new Date().toISOString()
const account = {
id: accountId,
name: accountData.name,
description: accountData.description || '',
accountType: accountData.accountType || 'shared',
groupId: accountData.groupId || null,
priority: accountData.priority || 50,
// Azure OpenAI 特有字段
azureEndpoint: accountData.azureEndpoint || '',
apiVersion: accountData.apiVersion || '2024-02-01', // 使用稳定版本
deploymentName: accountData.deploymentName || 'gpt-4', // 使用默认部署名称
apiKey: encrypt(accountData.apiKey || ''),
// 支持的模型
supportedModels: JSON.stringify(
accountData.supportedModels || ['gpt-4', 'gpt-4-turbo', 'gpt-35-turbo', 'gpt-35-turbo-16k']
),
// 状态字段
isActive: accountData.isActive !== false ? 'true' : 'false',
status: 'active',
schedulable: accountData.schedulable !== false ? 'true' : 'false',
createdAt: now,
updatedAt: now
}
// 代理配置
if (accountData.proxy) {
account.proxy =
typeof accountData.proxy === 'string' ? accountData.proxy : JSON.stringify(accountData.proxy)
}
const client = redisClient.getClientSafe()
await client.hset(`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, account)
// 如果是共享账户,添加到共享账户集合
if (account.accountType === 'shared') {
await client.sadd(SHARED_AZURE_OPENAI_ACCOUNTS_KEY, accountId)
}
logger.info(`Created Azure OpenAI account: ${accountId}`)
return account
}
// 获取账户
async function getAccount(accountId) {
const client = redisClient.getClientSafe()
const accountData = await client.hgetall(`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`)
if (!accountData || Object.keys(accountData).length === 0) {
return null
}
// 解密敏感数据(仅用于内部处理,不返回给前端)
if (accountData.apiKey) {
accountData.apiKey = decrypt(accountData.apiKey)
}
// 解析代理配置
if (accountData.proxy && typeof accountData.proxy === 'string') {
try {
accountData.proxy = JSON.parse(accountData.proxy)
} catch (e) {
accountData.proxy = null
}
}
// 解析支持的模型
if (accountData.supportedModels && typeof accountData.supportedModels === 'string') {
try {
accountData.supportedModels = JSON.parse(accountData.supportedModels)
} catch (e) {
accountData.supportedModels = ['gpt-4', 'gpt-35-turbo']
}
}
return accountData
}
// 更新账户
async function updateAccount(accountId, updates) {
const existingAccount = await getAccount(accountId)
if (!existingAccount) {
throw new Error('Account not found')
}
updates.updatedAt = new Date().toISOString()
// 加密敏感数据
if (updates.apiKey) {
updates.apiKey = encrypt(updates.apiKey)
}
// 处理代理配置
if (updates.proxy) {
updates.proxy =
typeof updates.proxy === 'string' ? updates.proxy : JSON.stringify(updates.proxy)
}
// 处理支持的模型
if (updates.supportedModels) {
updates.supportedModels =
typeof updates.supportedModels === 'string'
? updates.supportedModels
: JSON.stringify(updates.supportedModels)
}
// 更新账户类型时处理共享账户集合
const client = redisClient.getClientSafe()
if (updates.accountType && updates.accountType !== existingAccount.accountType) {
if (updates.accountType === 'shared') {
await client.sadd(SHARED_AZURE_OPENAI_ACCOUNTS_KEY, accountId)
} else {
await client.srem(SHARED_AZURE_OPENAI_ACCOUNTS_KEY, accountId)
}
}
await client.hset(`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, updates)
logger.info(`Updated Azure OpenAI account: ${accountId}`)
// 合并更新后的账户数据
const updatedAccount = { ...existingAccount, ...updates }
// 返回时解析代理配置
if (updatedAccount.proxy && typeof updatedAccount.proxy === 'string') {
try {
updatedAccount.proxy = JSON.parse(updatedAccount.proxy)
} catch (e) {
updatedAccount.proxy = null
}
}
return updatedAccount
}
// 删除账户
async function deleteAccount(accountId) {
const client = redisClient.getClientSafe()
const accountKey = `${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`
// 从Redis中删除账户数据
await client.del(accountKey)
// 从共享账户集合中移除
await client.srem(SHARED_AZURE_OPENAI_ACCOUNTS_KEY, accountId)
logger.info(`Deleted Azure OpenAI account: ${accountId}`)
return true
}
// 获取所有账户
async function getAllAccounts() {
const client = redisClient.getClientSafe()
const keys = await client.keys(`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}*`)
if (!keys || keys.length === 0) {
return []
}
const accounts = []
for (const key of keys) {
const accountData = await client.hgetall(key)
if (accountData && Object.keys(accountData).length > 0) {
// 不返回敏感数据给前端
delete accountData.apiKey
// 解析代理配置
if (accountData.proxy && typeof accountData.proxy === 'string') {
try {
accountData.proxy = JSON.parse(accountData.proxy)
} catch (e) {
accountData.proxy = null
}
}
// 解析支持的模型
if (accountData.supportedModels && typeof accountData.supportedModels === 'string') {
try {
accountData.supportedModels = JSON.parse(accountData.supportedModels)
} catch (e) {
accountData.supportedModels = ['gpt-4', 'gpt-35-turbo']
}
}
accounts.push(accountData)
}
}
return accounts
}
// 获取共享账户
async function getSharedAccounts() {
const client = redisClient.getClientSafe()
const accountIds = await client.smembers(SHARED_AZURE_OPENAI_ACCOUNTS_KEY)
if (!accountIds || accountIds.length === 0) {
return []
}
const accounts = []
for (const accountId of accountIds) {
const account = await getAccount(accountId)
if (account && account.isActive === 'true') {
accounts.push(account)
}
}
return accounts
}
// 选择可用账户
async function selectAvailableAccount(sessionId = null) {
// 如果有会话ID尝试获取之前分配的账户
if (sessionId) {
const client = redisClient.getClientSafe()
const mappingKey = `${ACCOUNT_SESSION_MAPPING_PREFIX}${sessionId}`
const accountId = await client.get(mappingKey)
if (accountId) {
const account = await getAccount(accountId)
if (account && account.isActive === 'true' && account.schedulable === 'true') {
logger.debug(`Reusing Azure OpenAI account ${accountId} for session ${sessionId}`)
return account
}
}
}
// 获取所有共享账户
const sharedAccounts = await getSharedAccounts()
// 过滤出可用的账户
const availableAccounts = sharedAccounts.filter(
(acc) => acc.isActive === 'true' && acc.schedulable === 'true'
)
if (availableAccounts.length === 0) {
throw new Error('No available Azure OpenAI accounts')
}
// 按优先级排序并选择
availableAccounts.sort((a, b) => (b.priority || 50) - (a.priority || 50))
const selectedAccount = availableAccounts[0]
// 如果有会话ID保存映射关系
if (sessionId && selectedAccount) {
const client = redisClient.getClientSafe()
const mappingKey = `${ACCOUNT_SESSION_MAPPING_PREFIX}${sessionId}`
await client.setex(mappingKey, 3600, selectedAccount.id) // 1小时过期
}
logger.debug(`Selected Azure OpenAI account: ${selectedAccount.id}`)
return selectedAccount
}
// 更新账户使用量
async function updateAccountUsage(accountId, tokens) {
const client = redisClient.getClientSafe()
const now = new Date().toISOString()
// 使用 HINCRBY 原子操作更新使用量
await client.hincrby(`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, 'totalTokensUsed', tokens)
await client.hset(`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, 'lastUsedAt', now)
logger.debug(`Updated Azure OpenAI account ${accountId} usage: ${tokens} tokens`)
}
// 健康检查单个账户
async function healthCheckAccount(accountId) {
try {
const account = await getAccount(accountId)
if (!account) {
return { id: accountId, status: 'error', message: 'Account not found' }
}
// 简单检查配置是否完整
if (!account.azureEndpoint || !account.apiKey || !account.deploymentName) {
return {
id: accountId,
status: 'error',
message: 'Incomplete configuration'
}
}
// 可以在这里添加实际的API调用测试
// 暂时返回成功状态
return {
id: accountId,
status: 'healthy',
message: 'Account is configured correctly'
}
} catch (error) {
logger.error(`Health check failed for Azure OpenAI account ${accountId}:`, error)
return {
id: accountId,
status: 'error',
message: error.message
}
}
}
// 批量健康检查
async function performHealthChecks() {
const accounts = await getAllAccounts()
const results = []
for (const account of accounts) {
const result = await healthCheckAccount(account.id)
results.push(result)
}
return results
}
// 切换账户的可调度状态
async function toggleSchedulable(accountId) {
const account = await getAccount(accountId)
if (!account) {
throw new Error('Account not found')
}
const newSchedulable = account.schedulable === 'true' ? 'false' : 'true'
await updateAccount(accountId, { schedulable: newSchedulable })
return {
id: accountId,
schedulable: newSchedulable === 'true'
}
}
// 迁移 API Keys 以支持 Azure OpenAI
async function migrateApiKeysForAzureSupport() {
const client = redisClient.getClientSafe()
const apiKeyIds = await client.smembers('api_keys')
let migratedCount = 0
for (const keyId of apiKeyIds) {
const keyData = await client.hgetall(`api_key:${keyId}`)
if (keyData && !keyData.azureOpenaiAccountId) {
// 添加 Azure OpenAI 账户ID字段初始为空
await client.hset(`api_key:${keyId}`, 'azureOpenaiAccountId', '')
migratedCount++
}
}
logger.info(`Migrated ${migratedCount} API keys for Azure OpenAI support`)
return migratedCount
}
module.exports = {
createAccount,
getAccount,
updateAccount,
deleteAccount,
getAllAccounts,
getSharedAccounts,
selectAvailableAccount,
updateAccountUsage,
healthCheckAccount,
performHealthChecks,
toggleSchedulable,
migrateApiKeysForAzureSupport,
encrypt,
decrypt
}

View File

@@ -0,0 +1,529 @@
const axios = require('axios')
const ProxyHelper = require('../utils/proxyHelper')
const logger = require('../utils/logger')
// 转换模型名称(去掉 azure/ 前缀)
function normalizeModelName(model) {
if (model && model.startsWith('azure/')) {
return model.replace('azure/', '')
}
return model
}
// 处理 Azure OpenAI 请求
async function handleAzureOpenAIRequest({
account,
requestBody,
headers: _headers = {}, // 前缀下划线表示未使用
isStream = false,
endpoint = 'chat/completions'
}) {
// 声明变量在函数顶部,确保在 catch 块中也能访问
let requestUrl = ''
let proxyAgent = null
let deploymentName = ''
try {
// 构建 Azure OpenAI 请求 URL
const baseUrl = account.azureEndpoint
deploymentName = account.deploymentName || 'default'
// Azure Responses API requires preview versions; fall back appropriately
const apiVersion =
account.apiVersion || (endpoint === 'responses' ? '2024-10-01-preview' : '2024-02-01')
if (endpoint === 'chat/completions') {
requestUrl = `${baseUrl}/openai/deployments/${deploymentName}/chat/completions?api-version=${apiVersion}`
} else if (endpoint === 'responses') {
requestUrl = `${baseUrl}/openai/responses?api-version=${apiVersion}`
} else {
requestUrl = `${baseUrl}/openai/deployments/${deploymentName}/${endpoint}?api-version=${apiVersion}`
}
// 准备请求头
const requestHeaders = {
'Content-Type': 'application/json',
'api-key': account.apiKey
}
// 移除不需要的头部
delete requestHeaders['anthropic-version']
delete requestHeaders['x-api-key']
delete requestHeaders['host']
// 处理请求体
const processedBody = { ...requestBody }
// 标准化模型名称
if (processedBody.model) {
processedBody.model = normalizeModelName(processedBody.model)
} else {
processedBody.model = 'gpt-4'
}
// 使用统一的代理创建工具
proxyAgent = ProxyHelper.createProxyAgent(account.proxy)
// 配置请求选项
const axiosConfig = {
method: 'POST',
url: requestUrl,
headers: requestHeaders,
data: processedBody,
timeout: 600000, // 10 minutes for Azure OpenAI
validateStatus: () => true,
// 添加连接保活选项
keepAlive: true,
maxRedirects: 5,
// 防止socket hang up
socketKeepAlive: true
}
// 如果有代理,添加代理配置
if (proxyAgent) {
axiosConfig.httpsAgent = proxyAgent
// 为代理添加额外的keep-alive设置
if (proxyAgent.options) {
proxyAgent.options.keepAlive = true
proxyAgent.options.keepAliveMsecs = 1000
}
logger.debug(
`Using proxy for Azure OpenAI request: ${ProxyHelper.getProxyDescription(account.proxy)}`
)
}
// 流式请求特殊处理
if (isStream) {
axiosConfig.responseType = 'stream'
requestHeaders.accept = 'text/event-stream'
} else {
requestHeaders.accept = 'application/json'
}
logger.debug(`Making Azure OpenAI request`, {
requestUrl,
method: 'POST',
endpoint,
deploymentName,
apiVersion,
hasProxy: !!proxyAgent,
proxyInfo: ProxyHelper.maskProxyInfo(account.proxy),
isStream,
requestBodySize: JSON.stringify(processedBody).length
})
logger.debug('Azure OpenAI request headers', {
'content-type': requestHeaders['Content-Type'],
'user-agent': requestHeaders['user-agent'] || 'not-set',
customHeaders: Object.keys(requestHeaders).filter(
(key) => !['Content-Type', 'user-agent'].includes(key)
)
})
logger.debug('Azure OpenAI request body', {
model: processedBody.model,
messages: processedBody.messages?.length || 0,
otherParams: Object.keys(processedBody).filter((key) => !['model', 'messages'].includes(key))
})
const requestStartTime = Date.now()
logger.debug(`🔄 Starting Azure OpenAI HTTP request at ${new Date().toISOString()}`)
// 发送请求
const response = await axios(axiosConfig)
const requestDuration = Date.now() - requestStartTime
logger.debug(`✅ Azure OpenAI HTTP request completed at ${new Date().toISOString()}`)
logger.debug(`Azure OpenAI response received`, {
status: response.status,
statusText: response.statusText,
duration: `${requestDuration}ms`,
responseHeaders: Object.keys(response.headers || {}),
hasData: !!response.data,
contentType: response.headers?.['content-type'] || 'unknown'
})
return response
} catch (error) {
const errorDetails = {
message: error.message,
code: error.code,
status: error.response?.status,
statusText: error.response?.statusText,
responseData: error.response?.data,
requestUrl: requestUrl || 'unknown',
endpoint,
deploymentName: deploymentName || account?.deploymentName || 'unknown',
hasProxy: !!proxyAgent,
proxyType: account?.proxy?.type || 'none',
isTimeout: error.code === 'ECONNABORTED',
isNetworkError: !error.response,
stack: error.stack
}
// 特殊错误类型的详细日志
if (error.code === 'ENOTFOUND') {
logger.error('DNS Resolution Failed for Azure OpenAI', {
...errorDetails,
hostname: requestUrl && requestUrl !== 'unknown' ? new URL(requestUrl).hostname : 'unknown',
suggestion: 'Check if Azure endpoint URL is correct and accessible'
})
} else if (error.code === 'ECONNREFUSED') {
logger.error('Connection Refused by Azure OpenAI', {
...errorDetails,
suggestion: 'Check if proxy settings are correct or Azure service is accessible'
})
} else if (error.code === 'ECONNRESET' || error.message.includes('socket hang up')) {
logger.error('🚨 Azure OpenAI Connection Reset / Socket Hang Up', {
...errorDetails,
suggestion:
'Connection was dropped by Azure OpenAI or proxy. This might be due to long request processing time, proxy timeout, or network instability. Try reducing request complexity or check proxy settings.'
})
} else if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') {
logger.error('🚨 Azure OpenAI Request Timeout', {
...errorDetails,
timeoutMs: 600000,
suggestion:
'Request exceeded 10-minute timeout. Consider reducing model complexity or check if Azure service is responding slowly.'
})
} else if (
error.code === 'CERT_AUTHORITY_INVALID' ||
error.code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE'
) {
logger.error('SSL Certificate Error for Azure OpenAI', {
...errorDetails,
suggestion: 'SSL certificate validation failed - check proxy SSL settings'
})
} else if (error.response?.status === 401) {
logger.error('Azure OpenAI Authentication Failed', {
...errorDetails,
suggestion: 'Check if Azure OpenAI API key is valid and not expired'
})
} else if (error.response?.status === 404) {
logger.error('Azure OpenAI Deployment Not Found', {
...errorDetails,
suggestion: 'Check if deployment name and Azure endpoint are correct'
})
} else {
logger.error('Azure OpenAI Request Failed', errorDetails)
}
throw error
}
}
// 安全的流管理器
class StreamManager {
constructor() {
this.activeStreams = new Set()
this.cleanupCallbacks = new Map()
}
registerStream(streamId, cleanup) {
this.activeStreams.add(streamId)
this.cleanupCallbacks.set(streamId, cleanup)
}
cleanup(streamId) {
if (this.activeStreams.has(streamId)) {
try {
const cleanup = this.cleanupCallbacks.get(streamId)
if (cleanup) {
cleanup()
}
} catch (error) {
logger.warn(`Stream cleanup error for ${streamId}:`, error.message)
} finally {
this.activeStreams.delete(streamId)
this.cleanupCallbacks.delete(streamId)
}
}
}
getActiveStreamCount() {
return this.activeStreams.size
}
}
const streamManager = new StreamManager()
// SSE 缓冲区大小限制
const MAX_BUFFER_SIZE = 64 * 1024 // 64KB
const MAX_EVENT_SIZE = 16 * 1024 // 16KB 单个事件最大大小
// 处理流式响应
function handleStreamResponse(upstreamResponse, clientResponse, options = {}) {
const { onData, onEnd, onError } = options
const streamId = `stream_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
logger.info(`Starting Azure OpenAI stream handling`, {
streamId,
upstreamStatus: upstreamResponse.status,
upstreamHeaders: Object.keys(upstreamResponse.headers || {}),
clientRemoteAddress: clientResponse.req?.connection?.remoteAddress,
hasOnData: !!onData,
hasOnEnd: !!onEnd,
hasOnError: !!onError
})
return new Promise((resolve, reject) => {
let buffer = ''
let usageData = null
let actualModel = null
let hasEnded = false
let eventCount = 0
const maxEvents = 10000 // 最大事件数量限制
// 设置响应头
clientResponse.setHeader('Content-Type', 'text/event-stream')
clientResponse.setHeader('Cache-Control', 'no-cache')
clientResponse.setHeader('Connection', 'keep-alive')
clientResponse.setHeader('X-Accel-Buffering', 'no')
// 透传某些头部
const passThroughHeaders = [
'x-request-id',
'x-ratelimit-remaining-requests',
'x-ratelimit-remaining-tokens'
]
passThroughHeaders.forEach((header) => {
const value = upstreamResponse.headers[header]
if (value) {
clientResponse.setHeader(header, value)
}
})
// 立即刷新响应头
if (typeof clientResponse.flushHeaders === 'function') {
clientResponse.flushHeaders()
}
// 解析 SSE 事件以捕获 usage 数据
const parseSSEForUsage = (data) => {
const lines = data.split('\n')
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const jsonStr = line.slice(6) // 移除 'data: ' 前缀
if (jsonStr.trim() === '[DONE]') {
continue
}
const eventData = JSON.parse(jsonStr)
// 获取模型信息
if (eventData.model) {
actualModel = eventData.model
}
// 获取使用统计Responses API: response.completed -> response.usage
if (eventData.type === 'response.completed' && eventData.response) {
if (eventData.response.model) {
actualModel = eventData.response.model
}
if (eventData.response.usage) {
usageData = eventData.response.usage
logger.debug('Captured Azure OpenAI nested usage (response.usage):', usageData)
}
}
// 兼容 Chat Completions 风格(顶层 usage
if (!usageData && eventData.usage) {
usageData = eventData.usage
logger.debug('Captured Azure OpenAI usage (top-level):', usageData)
}
// 检查是否是完成事件
if (eventData.choices && eventData.choices[0] && eventData.choices[0].finish_reason) {
// 这是最后一个 chunk
}
} catch (e) {
// 忽略解析错误
}
}
}
}
// 注册流清理
const cleanup = () => {
if (!hasEnded) {
hasEnded = true
try {
upstreamResponse.data?.removeAllListeners?.()
upstreamResponse.data?.destroy?.()
if (!clientResponse.headersSent) {
clientResponse.status(502).end()
} else if (!clientResponse.destroyed) {
clientResponse.end()
}
} catch (error) {
logger.warn('Stream cleanup error:', error.message)
}
}
}
streamManager.registerStream(streamId, cleanup)
upstreamResponse.data.on('data', (chunk) => {
try {
if (hasEnded || clientResponse.destroyed) {
return
}
eventCount++
if (eventCount > maxEvents) {
logger.warn(`Stream ${streamId} exceeded max events limit`)
cleanup()
return
}
const chunkStr = chunk.toString()
// 转发数据给客户端
if (!clientResponse.destroyed) {
clientResponse.write(chunk)
}
// 同时解析数据以捕获 usage 信息,带缓冲区大小限制
buffer += chunkStr
// 防止缓冲区过大
if (buffer.length > MAX_BUFFER_SIZE) {
logger.warn(`Stream ${streamId} buffer exceeded limit, truncating`)
buffer = buffer.slice(-MAX_BUFFER_SIZE / 2) // 保留后一半
}
// 处理完整的 SSE 事件
if (buffer.includes('\n\n')) {
const events = buffer.split('\n\n')
buffer = events.pop() || '' // 保留最后一个可能不完整的事件
for (const event of events) {
if (event.trim() && event.length <= MAX_EVENT_SIZE) {
parseSSEForUsage(event)
}
}
}
if (onData) {
onData(chunk, { usageData, actualModel })
}
} catch (error) {
logger.error('Error processing Azure OpenAI stream chunk:', error)
if (!hasEnded) {
cleanup()
reject(error)
}
}
})
upstreamResponse.data.on('end', () => {
if (hasEnded) {
return
}
streamManager.cleanup(streamId)
hasEnded = true
try {
// 处理剩余的 buffer
if (buffer.trim() && buffer.length <= MAX_EVENT_SIZE) {
parseSSEForUsage(buffer)
}
if (onEnd) {
onEnd({ usageData, actualModel })
}
if (!clientResponse.destroyed) {
clientResponse.end()
}
resolve({ usageData, actualModel })
} catch (error) {
logger.error('Stream end handling error:', error)
reject(error)
}
})
upstreamResponse.data.on('error', (error) => {
if (hasEnded) {
return
}
streamManager.cleanup(streamId)
hasEnded = true
logger.error('Upstream stream error:', error)
try {
if (onError) {
onError(error)
}
if (!clientResponse.headersSent) {
clientResponse.status(502).json({ error: { message: 'Upstream stream error' } })
} else if (!clientResponse.destroyed) {
clientResponse.end()
}
} catch (cleanupError) {
logger.warn('Error during stream error cleanup:', cleanupError.message)
}
reject(error)
})
// 客户端断开时清理
const clientCleanup = () => {
streamManager.cleanup(streamId)
}
clientResponse.on('close', clientCleanup)
clientResponse.on('aborted', clientCleanup)
clientResponse.on('error', clientCleanup)
})
}
// 处理非流式响应
function handleNonStreamResponse(upstreamResponse, clientResponse) {
try {
// 设置状态码
clientResponse.status(upstreamResponse.status)
// 设置响应头
clientResponse.setHeader('Content-Type', 'application/json')
// 透传某些头部
const passThroughHeaders = [
'x-request-id',
'x-ratelimit-remaining-requests',
'x-ratelimit-remaining-tokens'
]
passThroughHeaders.forEach((header) => {
const value = upstreamResponse.headers[header]
if (value) {
clientResponse.setHeader(header, value)
}
})
// 返回响应数据
const responseData = upstreamResponse.data
clientResponse.json(responseData)
// 提取 usage 数据
const usageData = responseData.usage
const actualModel = responseData.model
return { usageData, actualModel, responseData }
} catch (error) {
logger.error('Error handling Azure OpenAI non-stream response:', error)
throw error
}
}
module.exports = {
handleAzureOpenAIRequest,
handleStreamResponse,
handleNonStreamResponse,
normalizeModelName
}

View File

@@ -1,7 +1,6 @@
const { v4: uuidv4 } = require('uuid') const { v4: uuidv4 } = require('uuid')
const crypto = require('crypto') const crypto = require('crypto')
const { SocksProxyAgent } = require('socks-proxy-agent') const ProxyHelper = require('../utils/proxyHelper')
const { HttpsProxyAgent } = require('https-proxy-agent')
const axios = require('axios') const axios = require('axios')
const redis = require('../models/redis') const redis = require('../models/redis')
const logger = require('../utils/logger') const logger = require('../utils/logger')
@@ -55,6 +54,7 @@ class ClaudeAccountService {
proxy = null, // { type: 'socks5', host: 'localhost', port: 1080, username: '', password: '' } proxy = null, // { type: 'socks5', host: 'localhost', port: 1080, username: '', password: '' }
isActive = true, isActive = true,
accountType = 'shared', // 'dedicated' or 'shared' accountType = 'shared', // 'dedicated' or 'shared'
platform = 'claude',
priority = 50, // 调度优先级 (1-100数字越小优先级越高) priority = 50, // 调度优先级 (1-100数字越小优先级越高)
schedulable = true, // 是否可被调度 schedulable = true, // 是否可被调度
subscriptionInfo = null // 手动设置的订阅信息 subscriptionInfo = null // 手动设置的订阅信息
@@ -79,7 +79,8 @@ class ClaudeAccountService {
scopes: claudeAiOauth.scopes.join(' '), scopes: claudeAiOauth.scopes.join(' '),
proxy: proxy ? JSON.stringify(proxy) : '', proxy: proxy ? JSON.stringify(proxy) : '',
isActive: isActive.toString(), isActive: isActive.toString(),
accountType, // 账号类型:'dedicated' 或 'shared' accountType, // 账号类型:'dedicated' 或 'shared' 或 'group'
platform,
priority: priority.toString(), // 调度优先级 priority: priority.toString(), // 调度优先级
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
lastUsedAt: '', lastUsedAt: '',
@@ -108,7 +109,8 @@ class ClaudeAccountService {
scopes: '', scopes: '',
proxy: proxy ? JSON.stringify(proxy) : '', proxy: proxy ? JSON.stringify(proxy) : '',
isActive: isActive.toString(), isActive: isActive.toString(),
accountType, // 账号类型:'dedicated' 或 'shared' accountType, // 账号类型:'dedicated' 或 'shared' 或 'group'
platform,
priority: priority.toString(), // 调度优先级 priority: priority.toString(), // 调度优先级
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
lastUsedAt: '', lastUsedAt: '',
@@ -151,6 +153,7 @@ class ClaudeAccountService {
isActive, isActive,
proxy, proxy,
accountType, accountType,
platform,
priority, priority,
status: accountData.status, status: accountData.status,
createdAt: accountData.createdAt, createdAt: accountData.createdAt,
@@ -444,7 +447,7 @@ class ClaudeAccountService {
errorMessage: account.errorMessage, errorMessage: account.errorMessage,
accountType: account.accountType || 'shared', // 兼容旧数据,默认为共享 accountType: account.accountType || 'shared', // 兼容旧数据,默认为共享
priority: parseInt(account.priority) || 50, // 兼容旧数据默认优先级50 priority: parseInt(account.priority) || 50, // 兼容旧数据默认优先级50
platform: 'claude-oauth', // 添加平台标识,用于前端区分 platform: account.platform || 'claude', // 添加平台标识,用于前端区分
createdAt: account.createdAt, createdAt: account.createdAt,
lastUsedAt: account.lastUsedAt, lastUsedAt: account.lastUsedAt,
lastRefreshAt: account.lastRefreshAt, lastRefreshAt: account.lastRefreshAt,
@@ -857,29 +860,19 @@ class ClaudeAccountService {
} }
} }
// 🌐 创建代理agent // 🌐 创建代理agent(使用统一的代理工具)
_createProxyAgent(proxyConfig) { _createProxyAgent(proxyConfig) {
if (!proxyConfig) { const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
return null if (proxyAgent) {
logger.info(
`🌐 Using proxy for Claude request: ${ProxyHelper.getProxyDescription(proxyConfig)}`
)
} else if (proxyConfig) {
logger.debug('🌐 Failed to create proxy agent for Claude')
} else {
logger.debug('🌐 No proxy configured for Claude request')
} }
return proxyAgent
try {
const proxy = JSON.parse(proxyConfig)
if (proxy.type === 'socks5') {
const auth = proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : ''
const socksUrl = `socks5://${auth}${proxy.host}:${proxy.port}`
return new SocksProxyAgent(socksUrl)
} else if (proxy.type === 'http' || proxy.type === 'https') {
const auth = proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : ''
const httpUrl = `${proxy.type}://${auth}${proxy.host}:${proxy.port}`
return new HttpsProxyAgent(httpUrl)
}
} catch (error) {
logger.warn('⚠️ Invalid proxy configuration:', error)
}
return null
} }
// 🔐 加密敏感数据 // 🔐 加密敏感数据
@@ -1094,6 +1087,22 @@ class ClaudeAccountService {
logger.info(`🗑️ Deleted sticky session mapping for rate limited account: ${accountId}`) logger.info(`🗑️ Deleted sticky session mapping for rate limited account: ${accountId}`)
} }
// 发送Webhook通知
try {
const webhookNotifier = require('../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({
accountId,
accountName: accountData.name || 'Claude Account',
platform: 'claude-oauth',
status: 'error',
errorCode: 'CLAUDE_OAUTH_RATE_LIMITED',
reason: `Account rate limited (429 error). ${rateLimitResetTimestamp ? `Reset at: ${new Date(rateLimitResetTimestamp * 1000).toISOString()}` : 'Estimated reset in 1-5 hours'}`,
timestamp: new Date().toISOString()
})
} catch (webhookError) {
logger.error('Failed to send rate limit webhook notification:', webhookError)
}
return { success: true } return { success: true }
} catch (error) { } catch (error) {
logger.error(`❌ Failed to mark account as rate limited: ${accountId}`, error) logger.error(`❌ Failed to mark account as rate limited: ${accountId}`, error)

View File

@@ -1,7 +1,6 @@
const { v4: uuidv4 } = require('uuid') const { v4: uuidv4 } = require('uuid')
const crypto = require('crypto') const crypto = require('crypto')
const { SocksProxyAgent } = require('socks-proxy-agent') const ProxyHelper = require('../utils/proxyHelper')
const { HttpsProxyAgent } = require('https-proxy-agent')
const redis = require('../models/redis') const redis = require('../models/redis')
const logger = require('../utils/logger') const logger = require('../utils/logger')
const config = require('../../config/config') const config = require('../../config/config')
@@ -367,6 +366,22 @@ class ClaudeConsoleAccountService {
await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updates) await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updates)
// 发送Webhook通知
try {
const webhookNotifier = require('../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({
accountId,
accountName: account.name || 'Claude Console Account',
platform: 'claude-console',
status: 'error',
errorCode: 'CLAUDE_CONSOLE_RATE_LIMITED',
reason: `Account rate limited (429 error). ${account.rateLimitDuration ? `Will be blocked for ${account.rateLimitDuration} hours` : 'Temporary rate limit'}`,
timestamp: new Date().toISOString()
})
} catch (webhookError) {
logger.error('Failed to send rate limit webhook notification:', webhookError)
}
logger.warn( logger.warn(
`🚫 Claude Console account marked as rate limited: ${account.name} (${accountId})` `🚫 Claude Console account marked as rate limited: ${account.name} (${accountId})`
) )
@@ -480,29 +495,19 @@ class ClaudeConsoleAccountService {
} }
} }
// 🌐 创建代理agent // 🌐 创建代理agent(使用统一的代理工具)
_createProxyAgent(proxyConfig) { _createProxyAgent(proxyConfig) {
if (!proxyConfig) { const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
return null if (proxyAgent) {
logger.info(
`🌐 Using proxy for Claude Console request: ${ProxyHelper.getProxyDescription(proxyConfig)}`
)
} else if (proxyConfig) {
logger.debug('🌐 Failed to create proxy agent for Claude Console')
} else {
logger.debug('🌐 No proxy configured for Claude Console request')
} }
return proxyAgent
try {
const proxy = typeof proxyConfig === 'string' ? JSON.parse(proxyConfig) : proxyConfig
if (proxy.type === 'socks5') {
const auth = proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : ''
const socksUrl = `socks5://${auth}${proxy.host}:${proxy.port}`
return new SocksProxyAgent(socksUrl)
} else if (proxy.type === 'http' || proxy.type === 'https') {
const auth = proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : ''
const httpUrl = `${proxy.type}://${auth}${proxy.host}:${proxy.port}`
return new HttpsProxyAgent(httpUrl)
}
} catch (error) {
logger.warn('⚠️ Invalid proxy configuration:', error)
}
return null
} }
// 🔐 加密敏感数据 // 🔐 加密敏感数据

View File

@@ -84,7 +84,16 @@ class ClaudeConsoleRelayService {
// 构建完整的API URL // 构建完整的API URL
const cleanUrl = account.apiUrl.replace(/\/$/, '') // 移除末尾斜杠 const cleanUrl = account.apiUrl.replace(/\/$/, '') // 移除末尾斜杠
const apiEndpoint = cleanUrl.endsWith('/v1/messages') ? cleanUrl : `${cleanUrl}/v1/messages` let apiEndpoint
if (options.customPath) {
// 如果指定了自定义路径(如 count_tokens使用它
const baseUrl = cleanUrl.replace(/\/v1\/messages$/, '') // 移除已有的 /v1/messages
apiEndpoint = `${baseUrl}${options.customPath}`
} else {
// 默认使用 messages 端点
apiEndpoint = cleanUrl.endsWith('/v1/messages') ? cleanUrl : `${cleanUrl}/v1/messages`
}
logger.debug(`🎯 Final API endpoint: ${apiEndpoint}`) logger.debug(`🎯 Final API endpoint: ${apiEndpoint}`)
logger.debug(`[DEBUG] Options passed to relayRequest: ${JSON.stringify(options)}`) logger.debug(`[DEBUG] Options passed to relayRequest: ${JSON.stringify(options)}`)

View File

@@ -2,8 +2,7 @@ const https = require('https')
const zlib = require('zlib') const zlib = require('zlib')
const fs = require('fs') const fs = require('fs')
const path = require('path') const path = require('path')
const { SocksProxyAgent } = require('socks-proxy-agent') const ProxyHelper = require('../utils/proxyHelper')
const { HttpsProxyAgent } = require('https-proxy-agent')
const claudeAccountService = require('./claudeAccountService') const claudeAccountService = require('./claudeAccountService')
const unifiedClaudeScheduler = require('./unifiedClaudeScheduler') const unifiedClaudeScheduler = require('./unifiedClaudeScheduler')
const sessionHelper = require('../utils/sessionHelper') const sessionHelper = require('../utils/sessionHelper')
@@ -496,33 +495,29 @@ class ClaudeRelayService {
} }
} }
// 🌐 获取代理Agent // 🌐 获取代理Agent(使用统一的代理工具)
async _getProxyAgent(accountId) { async _getProxyAgent(accountId) {
try { try {
const accountData = await claudeAccountService.getAllAccounts() const accountData = await claudeAccountService.getAllAccounts()
const account = accountData.find((acc) => acc.id === accountId) const account = accountData.find((acc) => acc.id === accountId)
if (!account || !account.proxy) { if (!account || !account.proxy) {
logger.debug('🌐 No proxy configured for Claude account')
return null return null
} }
const { proxy } = account const proxyAgent = ProxyHelper.createProxyAgent(account.proxy)
if (proxyAgent) {
if (proxy.type === 'socks5') { logger.info(
const auth = proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : '' `🌐 Using proxy for Claude request: ${ProxyHelper.getProxyDescription(account.proxy)}`
const socksUrl = `socks5://${auth}${proxy.host}:${proxy.port}` )
return new SocksProxyAgent(socksUrl)
} else if (proxy.type === 'http' || proxy.type === 'https') {
const auth = proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : ''
const httpUrl = `${proxy.type}://${auth}${proxy.host}:${proxy.port}`
return new HttpsProxyAgent(httpUrl)
} }
return proxyAgent
} catch (error) { } catch (error) {
logger.warn('⚠️ Failed to create proxy agent:', error) logger.warn('⚠️ Failed to create proxy agent:', error)
}
return null return null
} }
}
// 🔧 过滤客户端请求头 // 🔧 过滤客户端请求头
_filterClientHeaders(clientHeaders) { _filterClientHeaders(clientHeaders) {
@@ -596,10 +591,18 @@ class ClaudeRelayService {
} }
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// 支持自定义路径(如 count_tokens
let requestPath = url.pathname
if (requestOptions.customPath) {
const baseUrl = new URL('https://api.anthropic.com')
const customUrl = new URL(requestOptions.customPath, baseUrl)
requestPath = customUrl.pathname
}
const options = { const options = {
hostname: url.hostname, hostname: url.hostname,
port: url.port || 443, port: url.port || 443,
path: url.pathname, path: requestPath,
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',

View File

@@ -5,6 +5,7 @@ const config = require('../../config/config')
const logger = require('../utils/logger') const logger = require('../utils/logger')
const { OAuth2Client } = require('google-auth-library') const { OAuth2Client } = require('google-auth-library')
const { maskToken } = require('../utils/tokenMask') const { maskToken } = require('../utils/tokenMask')
const ProxyHelper = require('../utils/proxyHelper')
const { const {
logRefreshStart, logRefreshStart,
logRefreshSuccess, logRefreshSuccess,
@@ -109,11 +110,32 @@ setInterval(
10 * 60 * 1000 10 * 60 * 1000
) )
// 创建 OAuth2 客户端 // 创建 OAuth2 客户端(支持代理配置)
function createOAuth2Client(redirectUri = null) { function createOAuth2Client(redirectUri = null, proxyConfig = null) {
// 如果没有提供 redirectUri使用默认值 // 如果没有提供 redirectUri使用默认值
const uri = redirectUri || 'http://localhost:45462' const uri = redirectUri || 'http://localhost:45462'
return new OAuth2Client(OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, uri)
// 准备客户端选项
const clientOptions = {
clientId: OAUTH_CLIENT_ID,
clientSecret: OAUTH_CLIENT_SECRET,
redirectUri: uri
}
// 如果有代理配置,设置 transporterOptions
if (proxyConfig) {
const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
if (proxyAgent) {
// 通过 transporterOptions 传递代理配置给底层的 Gaxios
clientOptions.transporterOptions = {
agent: proxyAgent,
httpsAgent: proxyAgent
}
logger.debug('Created OAuth2Client with proxy configuration')
}
}
return new OAuth2Client(clientOptions)
} }
// 生成授权 URL (支持 PKCE) // 生成授权 URL (支持 PKCE)
@@ -196,11 +218,25 @@ async function pollAuthorizationStatus(sessionId, maxAttempts = 60, interval = 2
} }
} }
// 交换授权码获取 tokens (支持 PKCE) // 交换授权码获取 tokens (支持 PKCE 和代理)
async function exchangeCodeForTokens(code, redirectUri = null, codeVerifier = null) { async function exchangeCodeForTokens(
const oAuth2Client = createOAuth2Client(redirectUri) code,
redirectUri = null,
codeVerifier = null,
proxyConfig = null
) {
try { try {
// 创建带代理配置的 OAuth2Client
const oAuth2Client = createOAuth2Client(redirectUri, proxyConfig)
if (proxyConfig) {
logger.info(
`🌐 Using proxy for Gemini token exchange: ${ProxyHelper.getProxyDescription(proxyConfig)}`
)
} else {
logger.debug('🌐 No proxy configured for Gemini token exchange')
}
const tokenParams = { const tokenParams = {
code, code,
redirect_uri: redirectUri redirect_uri: redirectUri
@@ -228,8 +264,9 @@ async function exchangeCodeForTokens(code, redirectUri = null, codeVerifier = nu
} }
// 刷新访问令牌 // 刷新访问令牌
async function refreshAccessToken(refreshToken) { async function refreshAccessToken(refreshToken, proxyConfig = null) {
const oAuth2Client = createOAuth2Client() // 创建带代理配置的 OAuth2Client
const oAuth2Client = createOAuth2Client(null, proxyConfig)
try { try {
// 设置 refresh_token // 设置 refresh_token
@@ -237,6 +274,14 @@ async function refreshAccessToken(refreshToken) {
refresh_token: refreshToken refresh_token: refreshToken
}) })
if (proxyConfig) {
logger.info(
`🔄 Using proxy for Gemini token refresh: ${ProxyHelper.maskProxyInfo(proxyConfig)}`
)
} else {
logger.debug('🔄 No proxy configured for Gemini token refresh')
}
// 调用 refreshAccessToken 获取新的 tokens // 调用 refreshAccessToken 获取新的 tokens
const response = await oAuth2Client.refreshAccessToken() const response = await oAuth2Client.refreshAccessToken()
const { credentials } = response const { credentials } = response
@@ -261,7 +306,9 @@ async function refreshAccessToken(refreshToken) {
logger.error('Error refreshing access token:', { logger.error('Error refreshing access token:', {
message: error.message, message: error.message,
code: error.code, code: error.code,
response: error.response?.data response: error.response?.data,
hasProxy: !!proxyConfig,
proxy: proxyConfig ? ProxyHelper.maskProxyInfo(proxyConfig) : 'No proxy'
}) })
throw new Error(`Failed to refresh access token: ${error.message}`) throw new Error(`Failed to refresh access token: ${error.message}`)
} }
@@ -786,7 +833,8 @@ async function refreshAccountToken(accountId) {
logger.info(`🔄 Starting token refresh for Gemini account: ${account.name} (${accountId})`) logger.info(`🔄 Starting token refresh for Gemini account: ${account.name} (${accountId})`)
// account.refreshToken 已经是解密后的值(从 getAccount 返回) // account.refreshToken 已经是解密后的值(从 getAccount 返回)
const newTokens = await refreshAccessToken(account.refreshToken) // 传入账户的代理配置
const newTokens = await refreshAccessToken(account.refreshToken, account.proxy)
// 更新账户信息 // 更新账户信息
const updates = { const updates = {
@@ -1169,7 +1217,8 @@ async function generateContent(
requestData, requestData,
userPromptId, userPromptId,
projectId = null, projectId = null,
sessionId = null sessionId = null,
proxyConfig = null
) { ) {
const axios = require('axios') const axios = require('axios')
const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com' const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com'
@@ -1206,6 +1255,17 @@ async function generateContent(
timeout: 60000 // 生成内容可能需要更长时间 timeout: 60000 // 生成内容可能需要更长时间
} }
// 添加代理配置
const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
if (proxyAgent) {
axiosConfig.httpsAgent = proxyAgent
logger.info(
`🌐 Using proxy for Gemini generateContent: ${ProxyHelper.getProxyDescription(proxyConfig)}`
)
} else {
logger.debug('🌐 No proxy configured for Gemini generateContent')
}
const response = await axios(axiosConfig) const response = await axios(axiosConfig)
logger.info('✅ generateContent API调用成功') logger.info('✅ generateContent API调用成功')
@@ -1219,7 +1279,8 @@ async function generateContentStream(
userPromptId, userPromptId,
projectId = null, projectId = null,
sessionId = null, sessionId = null,
signal = null signal = null,
proxyConfig = null
) { ) {
const axios = require('axios') const axios = require('axios')
const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com' const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com'
@@ -1260,6 +1321,17 @@ async function generateContentStream(
timeout: 60000 timeout: 60000
} }
// 添加代理配置
const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
if (proxyAgent) {
axiosConfig.httpsAgent = proxyAgent
logger.info(
`🌐 Using proxy for Gemini streamGenerateContent: ${ProxyHelper.getProxyDescription(proxyConfig)}`
)
} else {
logger.debug('🌐 No proxy configured for Gemini streamGenerateContent')
}
// 如果提供了中止信号,添加到配置中 // 如果提供了中止信号,添加到配置中
if (signal) { if (signal) {
axiosConfig.signal = signal axiosConfig.signal = signal

View File

@@ -1,6 +1,5 @@
const axios = require('axios') const axios = require('axios')
const { HttpsProxyAgent } = require('https-proxy-agent') const ProxyHelper = require('../utils/proxyHelper')
const { SocksProxyAgent } = require('socks-proxy-agent')
const logger = require('../utils/logger') const logger = require('../utils/logger')
const config = require('../../config/config') const config = require('../../config/config')
const apiKeyService = require('./apiKeyService') const apiKeyService = require('./apiKeyService')
@@ -9,34 +8,9 @@ const apiKeyService = require('./apiKeyService')
const GEMINI_API_BASE = 'https://cloudcode.googleapis.com/v1' const GEMINI_API_BASE = 'https://cloudcode.googleapis.com/v1'
const DEFAULT_MODEL = 'models/gemini-2.0-flash-exp' const DEFAULT_MODEL = 'models/gemini-2.0-flash-exp'
// 创建代理 agent // 创建代理 agent(使用统一的代理工具)
function createProxyAgent(proxyConfig) { function createProxyAgent(proxyConfig) {
if (!proxyConfig) { return ProxyHelper.createProxyAgent(proxyConfig)
return null
}
try {
const proxy = typeof proxyConfig === 'string' ? JSON.parse(proxyConfig) : proxyConfig
if (!proxy.type || !proxy.host || !proxy.port) {
return null
}
const proxyUrl =
proxy.username && proxy.password
? `${proxy.type}://${proxy.username}:${proxy.password}@${proxy.host}:${proxy.port}`
: `${proxy.type}://${proxy.host}:${proxy.port}`
if (proxy.type === 'socks5') {
return new SocksProxyAgent(proxyUrl)
} else if (proxy.type === 'http' || proxy.type === 'https') {
return new HttpsProxyAgent(proxyUrl)
}
} catch (error) {
logger.error('Error creating proxy agent:', error)
}
return null
} }
// 转换 OpenAI 消息格式到 Gemini 格式 // 转换 OpenAI 消息格式到 Gemini 格式
@@ -306,7 +280,9 @@ async function sendGeminiRequest({
const proxyAgent = createProxyAgent(proxy) const proxyAgent = createProxyAgent(proxy)
if (proxyAgent) { if (proxyAgent) {
axiosConfig.httpsAgent = proxyAgent axiosConfig.httpsAgent = proxyAgent
logger.debug('Using proxy for Gemini request') logger.info(`🌐 Using proxy for Gemini API request: ${ProxyHelper.getProxyDescription(proxy)}`)
} else {
logger.debug('🌐 No proxy configured for Gemini API request')
} }
// 添加 AbortController 信号支持 // 添加 AbortController 信号支持
@@ -412,6 +388,11 @@ async function getAvailableModels(accessToken, proxy, projectId, location = 'us-
const proxyAgent = createProxyAgent(proxy) const proxyAgent = createProxyAgent(proxy)
if (proxyAgent) { if (proxyAgent) {
axiosConfig.httpsAgent = proxyAgent axiosConfig.httpsAgent = proxyAgent
logger.info(
`🌐 Using proxy for Gemini models request: ${ProxyHelper.getProxyDescription(proxy)}`
)
} else {
logger.debug('🌐 No proxy configured for Gemini models request')
} }
try { try {
@@ -508,7 +489,11 @@ async function countTokens({
const proxyAgent = createProxyAgent(proxy) const proxyAgent = createProxyAgent(proxy)
if (proxyAgent) { if (proxyAgent) {
axiosConfig.httpsAgent = proxyAgent axiosConfig.httpsAgent = proxyAgent
logger.debug('Using proxy for Gemini countTokens request') logger.info(
`🌐 Using proxy for Gemini countTokens request: ${ProxyHelper.getProxyDescription(proxy)}`
)
} else {
logger.debug('🌐 No proxy configured for Gemini countTokens request')
} }
try { try {

View File

@@ -2,8 +2,7 @@ const redisClient = require('../models/redis')
const { v4: uuidv4 } = require('uuid') const { v4: uuidv4 } = require('uuid')
const crypto = require('crypto') const crypto = require('crypto')
const axios = require('axios') const axios = require('axios')
const { SocksProxyAgent } = require('socks-proxy-agent') const ProxyHelper = require('../utils/proxyHelper')
const { HttpsProxyAgent } = require('https-proxy-agent')
const config = require('../../config/config') const config = require('../../config/config')
const logger = require('../utils/logger') const logger = require('../utils/logger')
// const { maskToken } = require('../utils/tokenMask') // const { maskToken } = require('../utils/tokenMask')
@@ -133,18 +132,14 @@ async function refreshAccessToken(refreshToken, proxy = null) {
} }
// 配置代理(如果有) // 配置代理(如果有)
if (proxy && proxy.host && proxy.port) { const proxyAgent = ProxyHelper.createProxyAgent(proxy)
if (proxy.type === 'socks5') { if (proxyAgent) {
const proxyAuth = requestOptions.httpsAgent = proxyAgent
proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : '' logger.info(
const socksProxy = `socks5://${proxyAuth}${proxy.host}:${proxy.port}` `🌐 Using proxy for OpenAI token refresh: ${ProxyHelper.getProxyDescription(proxy)}`
requestOptions.httpsAgent = new SocksProxyAgent(socksProxy) )
} else if (proxy.type === 'http' || proxy.type === 'https') { } else {
const proxyAuth = logger.debug('🌐 No proxy configured for OpenAI token refresh')
proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : ''
const httpProxy = `http://${proxyAuth}${proxy.host}:${proxy.port}`
requestOptions.httpsAgent = new HttpsProxyAgent(httpProxy)
}
} }
// 发送请求 // 发送请求

View File

@@ -0,0 +1,272 @@
const redis = require('../models/redis')
const logger = require('../utils/logger')
const { v4: uuidv4 } = require('uuid')
class WebhookConfigService {
constructor() {
this.KEY_PREFIX = 'webhook_config'
this.DEFAULT_CONFIG_KEY = `${this.KEY_PREFIX}:default`
}
/**
* 获取webhook配置
*/
async getConfig() {
try {
const configStr = await redis.client.get(this.DEFAULT_CONFIG_KEY)
if (!configStr) {
// 返回默认配置
return this.getDefaultConfig()
}
return JSON.parse(configStr)
} catch (error) {
logger.error('获取webhook配置失败:', error)
return this.getDefaultConfig()
}
}
/**
* 保存webhook配置
*/
async saveConfig(config) {
try {
// 验证配置
this.validateConfig(config)
// 添加更新时间
config.updatedAt = new Date().toISOString()
await redis.client.set(this.DEFAULT_CONFIG_KEY, JSON.stringify(config))
logger.info('✅ Webhook配置已保存')
return config
} catch (error) {
logger.error('保存webhook配置失败:', error)
throw error
}
}
/**
* 验证配置
*/
validateConfig(config) {
if (!config || typeof config !== 'object') {
throw new Error('无效的配置格式')
}
// 验证平台配置
if (config.platforms) {
const validPlatforms = ['wechat_work', 'dingtalk', 'feishu', 'slack', 'discord', 'custom']
for (const platform of config.platforms) {
if (!validPlatforms.includes(platform.type)) {
throw new Error(`不支持的平台类型: ${platform.type}`)
}
if (!platform.url || !this.isValidUrl(platform.url)) {
throw new Error(`无效的webhook URL: ${platform.url}`)
}
// 验证平台特定的配置
this.validatePlatformConfig(platform)
}
}
}
/**
* 验证平台特定配置
*/
validatePlatformConfig(platform) {
switch (platform.type) {
case 'wechat_work':
// 企业微信不需要额外配置
break
case 'dingtalk':
// 钉钉可能需要secret用于签名
if (platform.enableSign && !platform.secret) {
throw new Error('钉钉启用签名时必须提供secret')
}
break
case 'feishu':
// 飞书可能需要签名
if (platform.enableSign && !platform.secret) {
throw new Error('飞书启用签名时必须提供secret')
}
break
case 'slack':
// Slack webhook URL通常包含token
if (!platform.url.includes('hooks.slack.com')) {
logger.warn('⚠️ Slack webhook URL格式可能不正确')
}
break
case 'discord':
// Discord webhook URL格式检查
if (!platform.url.includes('discord.com/api/webhooks')) {
logger.warn('⚠️ Discord webhook URL格式可能不正确')
}
break
case 'custom':
// 自定义webhook用户自行负责格式
break
}
}
/**
* 验证URL格式
*/
isValidUrl(url) {
try {
new URL(url)
return true
} catch {
return false
}
}
/**
* 获取默认配置
*/
getDefaultConfig() {
return {
enabled: false,
platforms: [],
notificationTypes: {
accountAnomaly: true, // 账号异常
quotaWarning: true, // 配额警告
systemError: true, // 系统错误
securityAlert: true, // 安全警报
test: true // 测试通知
},
retrySettings: {
maxRetries: 3,
retryDelay: 1000, // 毫秒
timeout: 10000 // 毫秒
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
}
}
/**
* 添加webhook平台
*/
async addPlatform(platform) {
try {
const config = await this.getConfig()
// 生成唯一ID
platform.id = platform.id || uuidv4()
platform.enabled = platform.enabled !== false
platform.createdAt = new Date().toISOString()
// 验证平台配置
this.validatePlatformConfig(platform)
// 添加到配置
config.platforms = config.platforms || []
config.platforms.push(platform)
await this.saveConfig(config)
return platform
} catch (error) {
logger.error('添加webhook平台失败:', error)
throw error
}
}
/**
* 更新webhook平台
*/
async updatePlatform(platformId, updates) {
try {
const config = await this.getConfig()
const index = config.platforms.findIndex((p) => p.id === platformId)
if (index === -1) {
throw new Error('找不到指定的webhook平台')
}
// 合并更新
config.platforms[index] = {
...config.platforms[index],
...updates,
updatedAt: new Date().toISOString()
}
// 验证更新后的配置
this.validatePlatformConfig(config.platforms[index])
await this.saveConfig(config)
return config.platforms[index]
} catch (error) {
logger.error('更新webhook平台失败:', error)
throw error
}
}
/**
* 删除webhook平台
*/
async deletePlatform(platformId) {
try {
const config = await this.getConfig()
config.platforms = config.platforms.filter((p) => p.id !== platformId)
await this.saveConfig(config)
logger.info(`✅ 已删除webhook平台: ${platformId}`)
return true
} catch (error) {
logger.error('删除webhook平台失败:', error)
throw error
}
}
/**
* 切换webhook平台启用状态
*/
async togglePlatform(platformId) {
try {
const config = await this.getConfig()
const platform = config.platforms.find((p) => p.id === platformId)
if (!platform) {
throw new Error('找不到指定的webhook平台')
}
platform.enabled = !platform.enabled
platform.updatedAt = new Date().toISOString()
await this.saveConfig(config)
logger.info(`✅ Webhook平台 ${platformId}${platform.enabled ? '启用' : '禁用'}`)
return platform
} catch (error) {
logger.error('切换webhook平台状态失败:', error)
throw error
}
}
/**
* 获取启用的平台列表
*/
async getEnabledPlatforms() {
try {
const config = await this.getConfig()
if (!config.enabled || !config.platforms) {
return []
}
return config.platforms.filter((p) => p.enabled)
} catch (error) {
logger.error('获取启用的webhook平台失败:', error)
return []
}
}
}
module.exports = new WebhookConfigService()

View File

@@ -0,0 +1,495 @@
const axios = require('axios')
const crypto = require('crypto')
const logger = require('../utils/logger')
const webhookConfigService = require('./webhookConfigService')
class WebhookService {
constructor() {
this.platformHandlers = {
wechat_work: this.sendToWechatWork.bind(this),
dingtalk: this.sendToDingTalk.bind(this),
feishu: this.sendToFeishu.bind(this),
slack: this.sendToSlack.bind(this),
discord: this.sendToDiscord.bind(this),
custom: this.sendToCustom.bind(this)
}
}
/**
* 发送通知到所有启用的平台
*/
async sendNotification(type, data) {
try {
const config = await webhookConfigService.getConfig()
// 检查是否启用webhook
if (!config.enabled) {
logger.debug('Webhook通知已禁用')
return
}
// 检查通知类型是否启用test类型始终允许发送
if (type !== 'test' && config.notificationTypes && !config.notificationTypes[type]) {
logger.debug(`通知类型 ${type} 已禁用`)
return
}
// 获取启用的平台
const enabledPlatforms = await webhookConfigService.getEnabledPlatforms()
if (enabledPlatforms.length === 0) {
logger.debug('没有启用的webhook平台')
return
}
logger.info(`📢 发送 ${type} 通知到 ${enabledPlatforms.length} 个平台`)
// 并发发送到所有平台
const promises = enabledPlatforms.map((platform) =>
this.sendToPlatform(platform, type, data, config.retrySettings)
)
const results = await Promise.allSettled(promises)
// 记录结果
const succeeded = results.filter((r) => r.status === 'fulfilled').length
const failed = results.filter((r) => r.status === 'rejected').length
if (failed > 0) {
logger.warn(`⚠️ Webhook通知: ${succeeded}成功, ${failed}失败`)
} else {
logger.info(`✅ 所有webhook通知发送成功`)
}
return { succeeded, failed }
} catch (error) {
logger.error('发送webhook通知失败:', error)
throw error
}
}
/**
* 发送到特定平台
*/
async sendToPlatform(platform, type, data, retrySettings) {
try {
const handler = this.platformHandlers[platform.type]
if (!handler) {
throw new Error(`不支持的平台类型: ${platform.type}`)
}
// 使用平台特定的处理器
await this.retryWithBackoff(
() => handler(platform, type, data),
retrySettings?.maxRetries || 3,
retrySettings?.retryDelay || 1000
)
logger.info(`✅ 成功发送到 ${platform.name || platform.type}`)
} catch (error) {
logger.error(`❌ 发送到 ${platform.name || platform.type} 失败:`, error.message)
throw error
}
}
/**
* 企业微信webhook
*/
async sendToWechatWork(platform, type, data) {
const content = this.formatMessageForWechatWork(type, data)
const payload = {
msgtype: 'markdown',
markdown: {
content
}
}
await this.sendHttpRequest(platform.url, payload, platform.timeout || 10000)
}
/**
* 钉钉webhook
*/
async sendToDingTalk(platform, type, data) {
const content = this.formatMessageForDingTalk(type, data)
let { url } = platform
const payload = {
msgtype: 'markdown',
markdown: {
title: this.getNotificationTitle(type),
text: content
}
}
// 如果启用签名
if (platform.enableSign && platform.secret) {
const timestamp = Date.now()
const sign = this.generateDingTalkSign(platform.secret, timestamp)
url = `${url}&timestamp=${timestamp}&sign=${encodeURIComponent(sign)}`
}
await this.sendHttpRequest(url, payload, platform.timeout || 10000)
}
/**
* 飞书webhook
*/
async sendToFeishu(platform, type, data) {
const content = this.formatMessageForFeishu(type, data)
const payload = {
msg_type: 'interactive',
card: {
elements: [
{
tag: 'markdown',
content
}
],
header: {
title: {
tag: 'plain_text',
content: this.getNotificationTitle(type)
},
template: this.getFeishuCardColor(type)
}
}
}
// 如果启用签名
if (platform.enableSign && platform.secret) {
const timestamp = Math.floor(Date.now() / 1000)
const sign = this.generateFeishuSign(platform.secret, timestamp)
payload.timestamp = timestamp.toString()
payload.sign = sign
}
await this.sendHttpRequest(platform.url, payload, platform.timeout || 10000)
}
/**
* Slack webhook
*/
async sendToSlack(platform, type, data) {
const text = this.formatMessageForSlack(type, data)
const payload = {
text,
username: 'Claude Relay Service',
icon_emoji: this.getSlackEmoji(type)
}
await this.sendHttpRequest(platform.url, payload, platform.timeout || 10000)
}
/**
* Discord webhook
*/
async sendToDiscord(platform, type, data) {
const embed = this.formatMessageForDiscord(type, data)
const payload = {
username: 'Claude Relay Service',
embeds: [embed]
}
await this.sendHttpRequest(platform.url, payload, platform.timeout || 10000)
}
/**
* 自定义webhook
*/
async sendToCustom(platform, type, data) {
// 使用通用格式
const payload = {
type,
service: 'claude-relay-service',
timestamp: new Date().toISOString(),
data
}
await this.sendHttpRequest(platform.url, payload, platform.timeout || 10000)
}
/**
* 发送HTTP请求
*/
async sendHttpRequest(url, payload, timeout) {
const response = await axios.post(url, payload, {
timeout,
headers: {
'Content-Type': 'application/json',
'User-Agent': 'claude-relay-service/2.0'
}
})
if (response.status < 200 || response.status >= 300) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
return response.data
}
/**
* 重试机制
*/
async retryWithBackoff(fn, maxRetries, baseDelay) {
let lastError
for (let i = 0; i < maxRetries; i++) {
try {
return await fn()
} catch (error) {
lastError = error
if (i < maxRetries - 1) {
const delay = baseDelay * Math.pow(2, i) // 指数退避
logger.debug(`🔄 重试 ${i + 1}/${maxRetries},等待 ${delay}ms`)
await new Promise((resolve) => setTimeout(resolve, delay))
}
}
}
throw lastError
}
/**
* 生成钉钉签名
*/
generateDingTalkSign(secret, timestamp) {
const stringToSign = `${timestamp}\n${secret}`
const hmac = crypto.createHmac('sha256', secret)
hmac.update(stringToSign)
return hmac.digest('base64')
}
/**
* 生成飞书签名
*/
generateFeishuSign(secret, timestamp) {
const stringToSign = `${timestamp}\n${secret}`
const hmac = crypto.createHmac('sha256', stringToSign)
hmac.update('')
return hmac.digest('base64')
}
/**
* 格式化企业微信消息
*/
formatMessageForWechatWork(type, data) {
const title = this.getNotificationTitle(type)
const details = this.formatNotificationDetails(data)
return (
`## ${title}\n\n` +
`> **服务**: Claude Relay Service\n` +
`> **时间**: ${new Date().toLocaleString('zh-CN')}\n\n${details}`
)
}
/**
* 格式化钉钉消息
*/
formatMessageForDingTalk(type, data) {
const details = this.formatNotificationDetails(data)
return (
`#### 服务: Claude Relay Service\n` +
`#### 时间: ${new Date().toLocaleString('zh-CN')}\n\n${details}`
)
}
/**
* 格式化飞书消息
*/
formatMessageForFeishu(type, data) {
return this.formatNotificationDetails(data)
}
/**
* 格式化Slack消息
*/
formatMessageForSlack(type, data) {
const title = this.getNotificationTitle(type)
const details = this.formatNotificationDetails(data)
return `*${title}*\n${details}`
}
/**
* 格式化Discord消息
*/
formatMessageForDiscord(type, data) {
const title = this.getNotificationTitle(type)
const color = this.getDiscordColor(type)
const fields = this.formatNotificationFields(data)
return {
title,
color,
fields,
timestamp: new Date().toISOString(),
footer: {
text: 'Claude Relay Service'
}
}
}
/**
* 获取通知标题
*/
getNotificationTitle(type) {
const titles = {
accountAnomaly: '⚠️ 账号异常通知',
quotaWarning: '📊 配额警告',
systemError: '❌ 系统错误',
securityAlert: '🔒 安全警报',
test: '🧪 测试通知'
}
return titles[type] || '📢 系统通知'
}
/**
* 格式化通知详情
*/
formatNotificationDetails(data) {
const lines = []
if (data.accountName) {
lines.push(`**账号**: ${data.accountName}`)
}
if (data.platform) {
lines.push(`**平台**: ${data.platform}`)
}
if (data.status) {
lines.push(`**状态**: ${data.status}`)
}
if (data.errorCode) {
lines.push(`**错误代码**: ${data.errorCode}`)
}
if (data.reason) {
lines.push(`**原因**: ${data.reason}`)
}
if (data.message) {
lines.push(`**消息**: ${data.message}`)
}
if (data.quota) {
lines.push(`**剩余配额**: ${data.quota.remaining}/${data.quota.total}`)
}
if (data.usage) {
lines.push(`**使用率**: ${data.usage}%`)
}
return lines.join('\n')
}
/**
* 格式化Discord字段
*/
formatNotificationFields(data) {
const fields = []
if (data.accountName) {
fields.push({ name: '账号', value: data.accountName, inline: true })
}
if (data.platform) {
fields.push({ name: '平台', value: data.platform, inline: true })
}
if (data.status) {
fields.push({ name: '状态', value: data.status, inline: true })
}
if (data.errorCode) {
fields.push({ name: '错误代码', value: data.errorCode, inline: false })
}
if (data.reason) {
fields.push({ name: '原因', value: data.reason, inline: false })
}
if (data.message) {
fields.push({ name: '消息', value: data.message, inline: false })
}
return fields
}
/**
* 获取飞书卡片颜色
*/
getFeishuCardColor(type) {
const colors = {
accountAnomaly: 'orange',
quotaWarning: 'yellow',
systemError: 'red',
securityAlert: 'red',
test: 'blue'
}
return colors[type] || 'blue'
}
/**
* 获取Slack emoji
*/
getSlackEmoji(type) {
const emojis = {
accountAnomaly: ':warning:',
quotaWarning: ':chart_with_downwards_trend:',
systemError: ':x:',
securityAlert: ':lock:',
test: ':test_tube:'
}
return emojis[type] || ':bell:'
}
/**
* 获取Discord颜色
*/
getDiscordColor(type) {
const colors = {
accountAnomaly: 0xff9800, // 橙色
quotaWarning: 0xffeb3b, // 黄色
systemError: 0xf44336, // 红色
securityAlert: 0xf44336, // 红色
test: 0x2196f3 // 蓝色
}
return colors[type] || 0x9e9e9e // 灰色
}
/**
* 测试webhook连接
*/
async testWebhook(platform) {
try {
const testData = {
message: 'Claude Relay Service webhook测试',
timestamp: new Date().toISOString()
}
await this.sendToPlatform(platform, 'test', testData, { maxRetries: 1, retryDelay: 1000 })
return { success: true }
} catch (error) {
return {
success: false,
error: error.message
}
}
}
}
module.exports = new WebhookService()

View File

@@ -5,7 +5,7 @@ const path = require('path')
const fs = require('fs') const fs = require('fs')
const os = require('os') const os = require('os')
// 安全的 JSON 序列化函数,处理循环引用 // 安全的 JSON 序列化函数,处理循环引用和特殊字符
const safeStringify = (obj, maxDepth = 3, fullDepth = false) => { const safeStringify = (obj, maxDepth = 3, fullDepth = false) => {
const seen = new WeakSet() const seen = new WeakSet()
// 如果是fullDepth模式增加深度限制 // 如果是fullDepth模式增加深度限制
@@ -16,6 +16,28 @@ const safeStringify = (obj, maxDepth = 3, fullDepth = false) => {
return '[Max Depth Reached]' return '[Max Depth Reached]'
} }
// 处理字符串值清理可能导致JSON解析错误的特殊字符
if (typeof value === 'string') {
try {
// 移除或转义可能导致JSON解析错误的字符
let cleanValue = value
// eslint-disable-next-line no-control-regex
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '') // 移除控制字符
.replace(/[\uD800-\uDFFF]/g, '') // 移除孤立的代理对字符
// eslint-disable-next-line no-control-regex
.replace(/\u0000/g, '') // 移除NUL字节
// 如果字符串过长,截断并添加省略号
if (cleanValue.length > 1000) {
cleanValue = `${cleanValue.substring(0, 997)}...`
}
return cleanValue
} catch (error) {
return '[Invalid String Data]'
}
}
if (value !== null && typeof value === 'object') { if (value !== null && typeof value === 'object') {
if (seen.has(value)) { if (seen.has(value)) {
return '[Circular Reference]' return '[Circular Reference]'
@@ -40,7 +62,10 @@ const safeStringify = (obj, maxDepth = 3, fullDepth = false) => {
} else { } else {
const result = {} const result = {}
for (const [k, v] of Object.entries(value)) { for (const [k, v] of Object.entries(value)) {
result[k] = replacer(k, v, depth + 1) // 确保键名也是安全的
// eslint-disable-next-line no-control-regex
const safeKey = typeof k === 'string' ? k.replace(/[\u0000-\u001F\u007F]/g, '') : k
result[safeKey] = replacer(safeKey, v, depth + 1)
} }
return result return result
} }
@@ -50,9 +75,20 @@ const safeStringify = (obj, maxDepth = 3, fullDepth = false) => {
} }
try { try {
return JSON.stringify(replacer('', obj)) const processed = replacer('', obj)
return JSON.stringify(processed)
} catch (error) { } catch (error) {
return JSON.stringify({ error: 'Failed to serialize object', message: error.message }) // 如果JSON.stringify仍然失败使用更保守的方法
try {
return JSON.stringify({
error: 'Failed to serialize object',
message: error.message,
type: typeof obj,
keys: obj && typeof obj === 'object' ? Object.keys(obj) : undefined
})
} catch (finalError) {
return '{"error":"Critical serialization failure","message":"Unable to serialize any data"}'
}
} }
} }
@@ -60,8 +96,8 @@ const safeStringify = (obj, maxDepth = 3, fullDepth = false) => {
const createLogFormat = (colorize = false) => { const createLogFormat = (colorize = false) => {
const formats = [ const formats = [
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.errors({ stack: true }), winston.format.errors({ stack: true })
winston.format.metadata({ fillExcept: ['message', 'level', 'timestamp', 'stack'] }) // 移除 winston.format.metadata() 来避免自动包装
] ]
if (colorize) { if (colorize) {
@@ -69,7 +105,7 @@ const createLogFormat = (colorize = false) => {
} }
formats.push( formats.push(
winston.format.printf(({ level, message, timestamp, stack, metadata, ...rest }) => { winston.format.printf(({ level, message, timestamp, stack, ...rest }) => {
const emoji = { const emoji = {
error: '❌', error: '❌',
warn: '⚠️ ', warn: '⚠️ ',
@@ -80,12 +116,7 @@ const createLogFormat = (colorize = false) => {
let logMessage = `${emoji[level] || '📝'} [${timestamp}] ${level.toUpperCase()}: ${message}` let logMessage = `${emoji[level] || '📝'} [${timestamp}] ${level.toUpperCase()}: ${message}`
// 添加元数据 // 直接处理额外数据不需要metadata包装
if (metadata && Object.keys(metadata).length > 0) {
logMessage += ` | ${safeStringify(metadata)}`
}
// 添加其他属性
const additionalData = { ...rest } const additionalData = { ...rest }
delete additionalData.level delete additionalData.level
delete additionalData.message delete additionalData.message

View File

@@ -4,8 +4,7 @@
*/ */
const crypto = require('crypto') const crypto = require('crypto')
const { SocksProxyAgent } = require('socks-proxy-agent') const ProxyHelper = require('./proxyHelper')
const { HttpsProxyAgent } = require('https-proxy-agent')
const axios = require('axios') const axios = require('axios')
const logger = require('./logger') const logger = require('./logger')
@@ -125,36 +124,12 @@ function generateSetupTokenParams() {
} }
/** /**
* 创建代理agent * 创建代理agent(使用统一的代理工具)
* @param {object|null} proxyConfig - 代理配置对象 * @param {object|null} proxyConfig - 代理配置对象
* @returns {object|null} 代理agent或null * @returns {object|null} 代理agent或null
*/ */
function createProxyAgent(proxyConfig) { function createProxyAgent(proxyConfig) {
if (!proxyConfig) { return ProxyHelper.createProxyAgent(proxyConfig)
return null
}
try {
if (proxyConfig.type === 'socks5') {
const auth =
proxyConfig.username && proxyConfig.password
? `${proxyConfig.username}:${proxyConfig.password}@`
: ''
const socksUrl = `socks5://${auth}${proxyConfig.host}:${proxyConfig.port}`
return new SocksProxyAgent(socksUrl)
} else if (proxyConfig.type === 'http' || proxyConfig.type === 'https') {
const auth =
proxyConfig.username && proxyConfig.password
? `${proxyConfig.username}:${proxyConfig.password}@`
: ''
const httpUrl = `${proxyConfig.type}://${auth}${proxyConfig.host}:${proxyConfig.port}`
return new HttpsProxyAgent(httpUrl)
}
} catch (error) {
console.warn('⚠️ Invalid proxy configuration:', error)
}
return null
} }
/** /**
@@ -182,6 +157,14 @@ async function exchangeCodeForTokens(authorizationCode, codeVerifier, state, pro
const agent = createProxyAgent(proxyConfig) const agent = createProxyAgent(proxyConfig)
try { try {
if (agent) {
logger.info(
`🌐 Using proxy for OAuth token exchange: ${ProxyHelper.maskProxyInfo(proxyConfig)}`
)
} else {
logger.debug('🌐 No proxy configured for OAuth token exchange')
}
logger.debug('🔄 Attempting OAuth token exchange', { logger.debug('🔄 Attempting OAuth token exchange', {
url: OAUTH_CONFIG.TOKEN_URL, url: OAUTH_CONFIG.TOKEN_URL,
codeLength: cleanedCode.length, codeLength: cleanedCode.length,
@@ -379,6 +362,14 @@ async function exchangeSetupTokenCode(authorizationCode, codeVerifier, state, pr
const agent = createProxyAgent(proxyConfig) const agent = createProxyAgent(proxyConfig)
try { try {
if (agent) {
logger.info(
`🌐 Using proxy for Setup Token exchange: ${ProxyHelper.maskProxyInfo(proxyConfig)}`
)
} else {
logger.debug('🌐 No proxy configured for Setup Token exchange')
}
logger.debug('🔄 Attempting Setup Token exchange', { logger.debug('🔄 Attempting Setup Token exchange', {
url: OAUTH_CONFIG.TOKEN_URL, url: OAUTH_CONFIG.TOKEN_URL,
codeLength: cleanedCode.length, codeLength: cleanedCode.length,

212
src/utils/proxyHelper.js Normal file
View File

@@ -0,0 +1,212 @@
const { SocksProxyAgent } = require('socks-proxy-agent')
const { HttpsProxyAgent } = require('https-proxy-agent')
const logger = require('./logger')
const config = require('../../config/config')
/**
* 统一的代理创建工具
* 支持 SOCKS5 和 HTTP/HTTPS 代理,可配置 IPv4/IPv6
*/
class ProxyHelper {
/**
* 创建代理 Agent
* @param {object|string|null} proxyConfig - 代理配置对象或 JSON 字符串
* @param {object} options - 额外选项
* @param {boolean|number} options.useIPv4 - 是否使用 IPv4 (true=IPv4, false=IPv6, undefined=auto)
* @returns {Agent|null} 代理 Agent 实例或 null
*/
static createProxyAgent(proxyConfig, options = {}) {
if (!proxyConfig) {
return null
}
try {
// 解析代理配置
const proxy = typeof proxyConfig === 'string' ? JSON.parse(proxyConfig) : proxyConfig
// 验证必要字段
if (!proxy.type || !proxy.host || !proxy.port) {
logger.warn('⚠️ Invalid proxy configuration: missing required fields (type, host, port)')
return null
}
// 获取 IPv4/IPv6 配置
const useIPv4 = ProxyHelper._getIPFamilyPreference(options.useIPv4)
// 构建认证信息
const auth = proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : ''
// 根据代理类型创建 Agent
if (proxy.type === 'socks5') {
const socksUrl = `socks5://${auth}${proxy.host}:${proxy.port}`
const socksOptions = {}
// 设置 IP 协议族(如果指定)
if (useIPv4 !== null) {
socksOptions.family = useIPv4 ? 4 : 6
}
return new SocksProxyAgent(socksUrl, socksOptions)
} else if (proxy.type === 'http' || proxy.type === 'https') {
const proxyUrl = `${proxy.type}://${auth}${proxy.host}:${proxy.port}`
const httpOptions = {}
// HttpsProxyAgent 支持 family 参数(通过底层的 agent-base
if (useIPv4 !== null) {
httpOptions.family = useIPv4 ? 4 : 6
}
return new HttpsProxyAgent(proxyUrl, httpOptions)
} else {
logger.warn(`⚠️ Unsupported proxy type: ${proxy.type}`)
return null
}
} catch (error) {
logger.warn('⚠️ Failed to create proxy agent:', error.message)
return null
}
}
/**
* 获取 IP 协议族偏好设置
* @param {boolean|number|string} preference - 用户偏好设置
* @returns {boolean|null} true=IPv4, false=IPv6, null=auto
* @private
*/
static _getIPFamilyPreference(preference) {
// 如果没有指定偏好,使用配置文件或默认值
if (preference === undefined) {
// 从配置文件读取默认设置,默认使用 IPv4
const defaultUseIPv4 = config.proxy?.useIPv4
if (defaultUseIPv4 !== undefined) {
return defaultUseIPv4
}
// 默认值IPv4兼容性更好
return true
}
// 处理各种输入格式
if (typeof preference === 'boolean') {
return preference
}
if (typeof preference === 'number') {
return preference === 4 ? true : preference === 6 ? false : null
}
if (typeof preference === 'string') {
const lower = preference.toLowerCase()
if (lower === 'ipv4' || lower === '4') {
return true
}
if (lower === 'ipv6' || lower === '6') {
return false
}
if (lower === 'auto' || lower === 'both') {
return null
}
}
// 无法识别的值返回默认IPv4
return true
}
/**
* 验证代理配置
* @param {object|string} proxyConfig - 代理配置
* @returns {boolean} 是否有效
*/
static validateProxyConfig(proxyConfig) {
if (!proxyConfig) {
return false
}
try {
const proxy = typeof proxyConfig === 'string' ? JSON.parse(proxyConfig) : proxyConfig
// 检查必要字段
if (!proxy.type || !proxy.host || !proxy.port) {
return false
}
// 检查支持的类型
if (!['socks5', 'http', 'https'].includes(proxy.type)) {
return false
}
// 检查端口范围
const port = parseInt(proxy.port)
if (isNaN(port) || port < 1 || port > 65535) {
return false
}
return true
} catch (error) {
return false
}
}
/**
* 获取代理配置的描述信息
* @param {object|string} proxyConfig - 代理配置
* @returns {string} 代理描述
*/
static getProxyDescription(proxyConfig) {
if (!proxyConfig) {
return 'No proxy'
}
try {
const proxy = typeof proxyConfig === 'string' ? JSON.parse(proxyConfig) : proxyConfig
const hasAuth = proxy.username && proxy.password
return `${proxy.type}://${proxy.host}:${proxy.port}${hasAuth ? ' (with auth)' : ''}`
} catch (error) {
return 'Invalid proxy config'
}
}
/**
* 脱敏代理配置信息用于日志记录
* @param {object|string} proxyConfig - 代理配置
* @returns {string} 脱敏后的代理信息
*/
static maskProxyInfo(proxyConfig) {
if (!proxyConfig) {
return 'No proxy'
}
try {
const proxy = typeof proxyConfig === 'string' ? JSON.parse(proxyConfig) : proxyConfig
let proxyDesc = `${proxy.type}://${proxy.host}:${proxy.port}`
// 如果有认证信息,进行脱敏处理
if (proxy.username && proxy.password) {
const maskedUsername =
proxy.username.length <= 2
? proxy.username
: proxy.username[0] +
'*'.repeat(Math.max(1, proxy.username.length - 2)) +
proxy.username.slice(-1)
const maskedPassword = '*'.repeat(Math.min(8, proxy.password.length))
proxyDesc += ` (auth: ${maskedUsername}:${maskedPassword})`
}
return proxyDesc
} catch (error) {
return 'Invalid proxy config'
}
}
/**
* 创建代理 Agent兼容旧的函数接口
* @param {object|string|null} proxyConfig - 代理配置
* @param {boolean} useIPv4 - 是否使用 IPv4
* @returns {Agent|null} 代理 Agent 实例或 null
* @deprecated 使用 createProxyAgent 替代
*/
static createProxy(proxyConfig, useIPv4 = true) {
logger.warn('⚠️ ProxyHelper.createProxy is deprecated, use createProxyAgent instead')
return ProxyHelper.createProxyAgent(proxyConfig, { useIPv4 })
}
}
module.exports = ProxyHelper

View File

@@ -1,13 +1,9 @@
const axios = require('axios')
const logger = require('./logger') const logger = require('./logger')
const config = require('../../config/config') const webhookService = require('../services/webhookService')
class WebhookNotifier { class WebhookNotifier {
constructor() { constructor() {
this.webhookUrls = config.webhook?.urls || [] // 保留此类用于兼容性实际功能委托给webhookService
this.timeout = config.webhook?.timeout || 10000
this.retries = config.webhook?.retries || 3
this.enabled = config.webhook?.enabled !== false
} }
/** /**
@@ -22,94 +18,40 @@ class WebhookNotifier {
* @param {string} notification.timestamp - 时间戳 * @param {string} notification.timestamp - 时间戳
*/ */
async sendAccountAnomalyNotification(notification) { async sendAccountAnomalyNotification(notification) {
if (!this.enabled || this.webhookUrls.length === 0) { try {
logger.debug('Webhook notification disabled or no URLs configured') // 使用新的webhookService发送通知
return await webhookService.sendNotification('accountAnomaly', {
}
const payload = {
type: 'account_anomaly',
data: {
accountId: notification.accountId, accountId: notification.accountId,
accountName: notification.accountName, accountName: notification.accountName,
platform: notification.platform, platform: notification.platform,
status: notification.status, status: notification.status,
errorCode: notification.errorCode, errorCode:
notification.errorCode || this._getErrorCode(notification.platform, notification.status),
reason: notification.reason, reason: notification.reason,
timestamp: notification.timestamp || new Date().toISOString(), timestamp: notification.timestamp || new Date().toISOString()
service: 'claude-relay-service'
}
}
logger.info(
`📢 Sending account anomaly webhook notification: ${notification.accountName} (${notification.accountId}) - ${notification.status}`
)
const promises = this.webhookUrls.map((url) => this._sendWebhook(url, payload))
try {
await Promise.allSettled(promises)
} catch (error) {
logger.error('Failed to send webhook notifications:', error)
}
}
/**
* 发送Webhook请求
* @param {string} url - Webhook URL
* @param {Object} payload - 请求载荷
*/
async _sendWebhook(url, payload, attempt = 1) {
try {
const response = await axios.post(url, payload, {
timeout: this.timeout,
headers: {
'Content-Type': 'application/json',
'User-Agent': 'claude-relay-service/webhook-notifier'
}
}) })
if (response.status >= 200 && response.status < 300) {
logger.info(`✅ Webhook sent successfully to ${url}`)
} else {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
} catch (error) { } catch (error) {
logger.error( logger.error('Failed to send account anomaly notification:', error)
`❌ Failed to send webhook to ${url} (attempt ${attempt}/${this.retries}):`,
error.message
)
// 重试机制
if (attempt < this.retries) {
const delay = Math.pow(2, attempt - 1) * 1000 // 指数退避
logger.info(`🔄 Retrying webhook to ${url} in ${delay}ms...`)
await new Promise((resolve) => setTimeout(resolve, delay))
return this._sendWebhook(url, payload, attempt + 1)
}
logger.error(`💥 All ${this.retries} webhook attempts failed for ${url}`)
} }
} }
/** /**
* 测试Webhook连通性 * 测试Webhook连通性(兼容旧接口)
* @param {string} url - Webhook URL * @param {string} url - Webhook URL
* @param {string} type - 平台类型(可选)
*/ */
async testWebhook(url) { async testWebhook(url, type = 'custom') {
const testPayload = { try {
type: 'test', // 创建临时平台配置
data: { const platform = {
message: 'Claude Relay Service webhook test', type,
timestamp: new Date().toISOString(), url,
service: 'claude-relay-service' enabled: true,
} timeout: 10000
} }
try { const result = await webhookService.testWebhook(platform)
await this._sendWebhook(url, testPayload) return result
return { success: true }
} catch (error) { } catch (error) {
return { success: false, error: error.message } return { success: false, error: error.message }
} }

View File

@@ -18,12 +18,14 @@
"vue-router": "^4.2.5" "vue-router": "^4.2.5"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.55.0",
"@vitejs/plugin-vue": "^4.5.2", "@vitejs/plugin-vue": "^4.5.2",
"@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-prettier": "^10.2.0",
"autoprefixer": "^10.4.16", "autoprefixer": "^10.4.16",
"eslint": "^8.55.0", "eslint": "^8.55.0",
"eslint-plugin-prettier": "^5.5.4", "eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-vue": "^9.19.2", "eslint-plugin-vue": "^9.19.2",
"playwright": "^1.55.0",
"postcss": "^8.4.32", "postcss": "^8.4.32",
"prettier": "^3.1.1", "prettier": "^3.1.1",
"prettier-plugin-tailwindcss": "^0.6.14", "prettier-plugin-tailwindcss": "^0.6.14",
@@ -806,6 +808,22 @@
"url": "https://opencollective.com/pkgr" "url": "https://opencollective.com/pkgr"
} }
}, },
"node_modules/@playwright/test": {
"version": "1.55.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0.tgz",
"integrity": "sha512-04IXzPwHrW69XusN/SIdDdKZBzMfOT9UNT/YiJit/xpy2VuAoB8NHc8Aplb96zsWDddLnbkPL3TsmrS04ZU2xQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.55.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@popperjs/core": { "node_modules/@popperjs/core": {
"name": "@sxzz/popperjs-es", "name": "@sxzz/popperjs-es",
"version": "2.11.7", "version": "2.11.7",
@@ -3465,6 +3483,53 @@
"pathe": "^2.0.1" "pathe": "^2.0.1"
} }
}, },
"node_modules/playwright": {
"version": "1.55.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0.tgz",
"integrity": "sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.55.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.55.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0.tgz",
"integrity": "sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.6", "version": "8.5.6",
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.6.tgz", "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.6.tgz",
@@ -3655,7 +3720,7 @@
}, },
"node_modules/prettier-plugin-tailwindcss": { "node_modules/prettier-plugin-tailwindcss": {
"version": "0.6.14", "version": "0.6.14",
"resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.14.tgz", "resolved": "https://registry.npmmirror.com/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.14.tgz",
"integrity": "sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg==", "integrity": "sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",

View File

@@ -21,12 +21,14 @@
"vue-router": "^4.2.5" "vue-router": "^4.2.5"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.55.0",
"@vitejs/plugin-vue": "^4.5.2", "@vitejs/plugin-vue": "^4.5.2",
"@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-prettier": "^10.2.0",
"autoprefixer": "^10.4.16", "autoprefixer": "^10.4.16",
"eslint": "^8.55.0", "eslint": "^8.55.0",
"eslint-plugin-prettier": "^5.5.4", "eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-vue": "^9.19.2", "eslint-plugin-vue": "^9.19.2",
"playwright": "^1.55.0",
"postcss": "^8.4.32", "postcss": "^8.4.32",
"prettier": "^3.1.1", "prettier": "^3.1.1",
"prettier-plugin-tailwindcss": "^0.6.14", "prettier-plugin-tailwindcss": "^0.6.14",

View File

@@ -11,14 +11,22 @@
<script setup> <script setup>
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useThemeStore } from '@/stores/theme'
import ToastNotification from '@/components/common/ToastNotification.vue' import ToastNotification from '@/components/common/ToastNotification.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue' import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
const authStore = useAuthStore() const authStore = useAuthStore()
const themeStore = useThemeStore()
const toastRef = ref() const toastRef = ref()
const confirmRef = ref() const confirmRef = ref()
onMounted(() => { onMounted(() => {
// 初始化主题
themeStore.initTheme()
// 监听系统主题变化
themeStore.watchSystemTheme()
// 检查本地存储的认证状态 // 检查本地存储的认证状态
authStore.checkAuth() authStore.checkAuth()

View File

@@ -1,5 +1,6 @@
/* 从原始 style.css 复制的全局样式 */ /* 从原始 style.css 复制的全局样式 */
:root { :root {
/* 亮色模式 */
--primary-color: #667eea; --primary-color: #667eea;
--secondary-color: #764ba2; --secondary-color: #764ba2;
--accent-color: #f093fb; --accent-color: #f093fb;
@@ -8,31 +9,62 @@
--error-color: #ef4444; --error-color: #ef4444;
--surface-color: rgba(255, 255, 255, 0.95); --surface-color: rgba(255, 255, 255, 0.95);
--glass-color: rgba(255, 255, 255, 0.1); --glass-color: rgba(255, 255, 255, 0.1);
--glass-strong-color: rgba(255, 255, 255, 0.95);
--text-primary: #1f2937; --text-primary: #1f2937;
--text-secondary: #6b7280; --text-secondary: #6b7280;
--border-color: rgba(255, 255, 255, 0.2); --border-color: rgba(255, 255, 255, 0.2);
--bg-gradient-start: #667eea;
--bg-gradient-mid: #764ba2;
--bg-gradient-end: #f093fb;
--input-bg: rgba(255, 255, 255, 0.9);
--input-border: rgba(255, 255, 255, 0.3);
--modal-bg: rgba(0, 0, 0, 0.4);
--table-bg: rgba(255, 255, 255, 0.95);
--table-hover: rgba(102, 126, 234, 0.05);
} }
/* 通用transition - 仅应用于特定元素 */ .dark {
body, /* 暗黑模式 */
div, --primary-color: #818cf8;
--secondary-color: #a78bfa;
--accent-color: #c084fc;
--success-color: #10b981;
--warning-color: #f59e0b;
--error-color: #ef4444;
--surface-color: rgba(31, 41, 55, 0.95);
--glass-color: rgba(0, 0, 0, 0.2);
--glass-strong-color: rgba(31, 41, 55, 0.95);
--text-primary: #f3f4f6;
--text-secondary: #9ca3af;
--border-color: rgba(75, 85, 99, 0.3);
--bg-gradient-start: #1f2937;
--bg-gradient-mid: #374151;
--bg-gradient-end: #4b5563;
--input-bg: rgba(31, 41, 55, 0.9);
--input-border: rgba(75, 85, 99, 0.5);
--modal-bg: rgba(0, 0, 0, 0.6);
--table-bg: rgba(31, 41, 55, 0.95);
--table-hover: rgba(129, 140, 248, 0.1);
}
/* 优化后的transition - 避免布局跳动 */
button, button,
input, input,
select, select,
textarea, textarea {
table, transition:
tr, background-color 0.3s ease,
td, border-color 0.3s ease,
th, box-shadow 0.3s ease,
span, transform 0.2s ease;
p, }
h1,
h2, /* 颜色和背景过渡 */
h3, .transition-colors {
h4, transition:
h5, color 0.3s ease,
h6 { background-color 0.3s ease,
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); border-color 0.3s ease;
} }
body { body {
@@ -45,14 +77,18 @@ body {
sans-serif; sans-serif;
background: linear-gradient( background: linear-gradient(
135deg, 135deg,
var(--primary-color) 0%, var(--bg-gradient-start) 0%,
var(--secondary-color) 50%, var(--bg-gradient-mid) 50%,
var(--accent-color) 100% var(--bg-gradient-end) 100%
); );
background-attachment: fixed; background-attachment: fixed;
min-height: 100vh; min-height: 100vh;
margin: 0; margin: 0;
overflow-x: hidden; overflow-x: hidden;
color: var(--text-primary);
transition:
background 0.3s ease,
color 0.3s ease;
} }
body::before { body::before {
@@ -73,21 +109,43 @@ body::before {
.glass { .glass {
background: var(--glass-color); background: var(--glass-color);
backdrop-filter: blur(20px); backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
box-shadow: box-shadow:
0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 20px 25px -5px rgba(0, 0, 0, 0.1),
0 10px 10px -5px rgba(0, 0, 0, 0.04), 0 10px 10px -5px rgba(0, 0, 0, 0.04),
inset 0 1px 0 rgba(255, 255, 255, 0.1); inset 0 1px 0 rgba(255, 255, 255, 0.1);
transition:
background-color 0.3s ease,
border-color 0.3s ease;
}
.dark .glass {
box-shadow:
0 20px 25px -5px rgba(0, 0, 0, 0.25),
0 10px 10px -5px rgba(0, 0, 0, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
} }
.glass-strong { .glass-strong {
background: var(--surface-color); background: var(--glass-strong-color);
backdrop-filter: blur(25px); backdrop-filter: blur(25px);
border: 1px solid rgba(255, 255, 255, 0.3); -webkit-backdrop-filter: blur(25px);
border: 1px solid var(--border-color);
box-shadow: box-shadow:
0 25px 50px -12px rgba(0, 0, 0, 0.25), 0 25px 50px -12px rgba(0, 0, 0, 0.25),
0 0 0 1px rgba(255, 255, 255, 0.05), 0 0 0 1px rgba(255, 255, 255, 0.05),
inset 0 1px 0 rgba(255, 255, 255, 0.1); inset 0 1px 0 rgba(255, 255, 255, 0.1);
transition:
background-color 0.3s ease,
border-color 0.3s ease;
}
.dark .glass-strong {
box-shadow:
0 25px 50px -12px rgba(0, 0, 0, 0.4),
0 0 0 1px rgba(255, 255, 255, 0.02),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
} }
.tab-btn { .tab-btn {
@@ -201,13 +259,18 @@ body::before {
/* 表单输入框样式 */ /* 表单输入框样式 */
.form-input { .form-input {
background: rgba(255, 255, 255, 0.9); background: var(--input-bg);
border: 2px solid rgba(255, 255, 255, 0.3); border: 2px solid var(--input-border);
border-radius: 12px; border-radius: 12px;
padding: 16px; padding: 16px;
font-size: 16px; font-size: 16px;
transition: all 0.3s ease; color: var(--text-primary);
transition:
background-color 0.3s ease,
border-color 0.3s ease,
box-shadow 0.3s ease;
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
} }
.form-input:focus { .form-input:focus {
@@ -217,17 +280,35 @@ body::before {
0 0 0 3px rgba(102, 126, 234, 0.1), 0 0 0 3px rgba(102, 126, 234, 0.1),
0 10px 15px -3px rgba(0, 0, 0, 0.1); 0 10px 15px -3px rgba(0, 0, 0, 0.1);
background: rgba(255, 255, 255, 0.95); background: rgba(255, 255, 255, 0.95);
color: #1f2937;
}
.dark .form-input:focus {
box-shadow:
0 0 0 3px rgba(129, 140, 248, 0.2),
0 10px 15px -3px rgba(0, 0, 0, 0.2);
background: rgba(17, 24, 39, 0.95);
color: #f3f4f6;
} }
.card { .card {
background: var(--surface-color); background: var(--surface-color);
border-radius: 16px; border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.2); border: 1px solid var(--border-color);
box-shadow: box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05); 0 4px 6px -2px rgba(0, 0, 0, 0.05);
overflow: hidden; overflow: hidden;
position: relative; position: relative;
transition:
background-color 0.3s ease,
border-color 0.3s ease;
}
.dark .card {
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.25),
0 4px 6px -2px rgba(0, 0, 0, 0.1);
} }
.card::before { .card::before {
@@ -241,13 +322,19 @@ body::before {
} }
.stat-card { .stat-card {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.95) 0%, rgba(255, 255, 255, 0.8) 100%); background: linear-gradient(135deg, var(--surface-color) 0%, var(--glass-strong-color) 100%);
border-radius: 20px; border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.3); border: 1px solid var(--border-color);
padding: 24px; padding: 24px;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
transition: all 0.3s ease; transition:
transform 0.3s ease,
box-shadow 0.3s ease;
}
.dark .stat-card {
background: linear-gradient(135deg, rgba(31, 41, 55, 0.95) 0%, rgba(17, 24, 39, 0.8) 100%);
} }
.stat-card::before { .stat-card::before {
@@ -364,13 +451,18 @@ body::before {
} }
.form-input { .form-input {
background: rgba(255, 255, 255, 0.9); background: var(--input-bg);
border: 2px solid rgba(255, 255, 255, 0.3); border: 2px solid var(--input-border);
border-radius: 12px; border-radius: 12px;
padding: 16px; padding: 16px;
font-size: 16px; font-size: 16px;
transition: all 0.3s ease; color: var(--text-primary);
transition:
background-color 0.3s ease,
border-color 0.3s ease,
box-shadow 0.3s ease;
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
} }
.form-input:focus { .form-input:focus {
@@ -380,39 +472,71 @@ body::before {
0 0 0 3px rgba(102, 126, 234, 0.1), 0 0 0 3px rgba(102, 126, 234, 0.1),
0 10px 15px -3px rgba(0, 0, 0, 0.1); 0 10px 15px -3px rgba(0, 0, 0, 0.1);
background: rgba(255, 255, 255, 0.95); background: rgba(255, 255, 255, 0.95);
color: #1f2937;
}
.dark .form-input:focus {
box-shadow:
0 0 0 3px rgba(129, 140, 248, 0.2),
0 10px 15px -3px rgba(0, 0, 0, 0.2);
background: rgba(17, 24, 39, 0.95);
color: #f3f4f6;
} }
.table-container { .table-container {
background: rgba(255, 255, 255, 0.95); background: var(--table-bg);
border-radius: 16px; border-radius: 16px;
overflow: hidden; overflow: hidden;
box-shadow: box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05); 0 4px 6px -2px rgba(0, 0, 0, 0.05);
transition: background-color 0.3s ease;
}
.dark .table-container {
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.25),
0 4px 6px -2px rgba(0, 0, 0, 0.1);
} }
.table-row { .table-row {
transition: all 0.2s ease; transition:
background-color 0.2s ease,
transform 0.2s ease;
} }
.table-row:hover { .table-row:hover {
background: rgba(102, 126, 234, 0.05); background: var(--table-hover);
transform: scale(1.005); transform: scale(1.005);
} }
.modal { .modal {
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
background: rgba(0, 0, 0, 0.4); -webkit-backdrop-filter: blur(8px);
background: var(--modal-bg);
transition: background-color 0.3s ease;
} }
.modal-content { .modal-content {
background: rgba(255, 255, 255, 0.95); background: white;
border-radius: 24px; border-radius: 24px;
border: 1px solid rgba(255, 255, 255, 0.3); border: 1px solid rgba(229, 231, 235, 0.8);
box-shadow: box-shadow:
0 25px 50px -12px rgba(0, 0, 0, 0.25), 0 25px 50px -12px rgba(0, 0, 0, 0.25),
0 0 0 1px rgba(255, 255, 255, 0.05); 0 0 0 1px rgba(255, 255, 255, 0.05);
backdrop-filter: blur(20px); backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
transition:
background-color 0.3s ease,
border-color 0.3s ease;
}
.dark .modal-content {
background: #1f2937;
border: 1px solid rgba(75, 85, 99, 0.5);
box-shadow:
0 25px 50px -12px rgba(0, 0, 0, 0.6),
0 0 0 1px rgba(255, 255, 255, 0.02);
} }
.header-title { .header-title {
@@ -517,6 +641,10 @@ body::before {
scrollbar-color: rgba(102, 126, 234, 0.3) rgba(102, 126, 234, 0.05); scrollbar-color: rgba(102, 126, 234, 0.3) rgba(102, 126, 234, 0.05);
} }
.dark .custom-scrollbar {
scrollbar-color: rgba(129, 140, 248, 0.3) rgba(129, 140, 248, 0.05);
}
.custom-scrollbar::-webkit-scrollbar { .custom-scrollbar::-webkit-scrollbar {
width: 8px; width: 8px;
height: 8px; height: 8px;
@@ -527,20 +655,36 @@ body::before {
border-radius: 10px; border-radius: 10px;
} }
.dark .custom-scrollbar::-webkit-scrollbar-track {
background: rgba(129, 140, 248, 0.05);
}
.custom-scrollbar::-webkit-scrollbar-thumb { .custom-scrollbar::-webkit-scrollbar-thumb {
background: linear-gradient(135deg, rgba(102, 126, 234, 0.4) 0%, rgba(118, 75, 162, 0.4) 100%); background: linear-gradient(135deg, rgba(102, 126, 234, 0.4) 0%, rgba(118, 75, 162, 0.4) 100%);
border-radius: 10px; border-radius: 10px;
transition: background 0.3s ease; transition: background 0.3s ease;
} }
.dark .custom-scrollbar::-webkit-scrollbar-thumb {
background: linear-gradient(135deg, rgba(129, 140, 248, 0.4) 0%, rgba(167, 139, 250, 0.4) 100%);
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover { .custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: linear-gradient(135deg, rgba(102, 126, 234, 0.6) 0%, rgba(118, 75, 162, 0.6) 100%); background: linear-gradient(135deg, rgba(102, 126, 234, 0.6) 0%, rgba(118, 75, 162, 0.6) 100%);
} }
.dark .custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: linear-gradient(135deg, rgba(129, 140, 248, 0.6) 0%, rgba(167, 139, 250, 0.6) 100%);
}
.custom-scrollbar::-webkit-scrollbar-thumb:active { .custom-scrollbar::-webkit-scrollbar-thumb:active {
background: linear-gradient(135deg, rgba(102, 126, 234, 0.8) 0%, rgba(118, 75, 162, 0.8) 100%); background: linear-gradient(135deg, rgba(102, 126, 234, 0.8) 0%, rgba(118, 75, 162, 0.8) 100%);
} }
.dark .custom-scrollbar::-webkit-scrollbar-thumb:active {
background: linear-gradient(135deg, rgba(129, 140, 248, 0.8) 0%, rgba(167, 139, 250, 0.8) 100%);
}
/* 弹窗滚动内容样式 */ /* 弹窗滚动内容样式 */
.modal-scroll-content { .modal-scroll-content {
max-height: calc(90vh - 160px); max-height: calc(90vh - 160px);

File diff suppressed because it is too large Load Diff

View File

@@ -172,7 +172,7 @@
<!-- 编辑分组模态框 --> <!-- 编辑分组模态框 -->
<div <div
v-if="showEditForm" v-if="showEditForm"
class="modal z-60 fixed inset-0 flex items-center justify-center p-3 sm:p-4" class="modal fixed inset-0 z-50 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="modal-content w-full max-w-lg p-4 sm:p-6">
<div class="mb-4 flex items-center justify-between"> <div class="mb-4 flex items-center justify-between">

View File

@@ -2,7 +2,9 @@
<div class="space-y-6"> <div class="space-y-6">
<!-- Claude OAuth流程 --> <!-- Claude OAuth流程 -->
<div v-if="platform === 'claude'"> <div v-if="platform === 'claude'">
<div class="rounded-lg border border-blue-200 bg-blue-50 p-6"> <div
class="rounded-lg border border-blue-200 bg-blue-50 p-6 dark:border-blue-700 dark:bg-blue-900/30"
>
<div class="flex items-start gap-4"> <div class="flex items-start gap-4">
<div <div
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-blue-500" class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-blue-500"
@@ -10,12 +12,16 @@
<i class="fas fa-link text-white" /> <i class="fas fa-link text-white" />
</div> </div>
<div class="flex-1"> <div class="flex-1">
<h4 class="mb-3 font-semibold text-blue-900">Claude 账户授权</h4> <h4 class="mb-3 font-semibold text-blue-900 dark:text-blue-200">Claude 账户授权</h4>
<p class="mb-4 text-sm text-blue-800">请按照以下步骤完成 Claude 账户的授权</p> <p class="mb-4 text-sm text-blue-800 dark:text-blue-300">
请按照以下步骤完成 Claude 账户的授权
</p>
<div class="space-y-4"> <div class="space-y-4">
<!-- 步骤1: 生成授权链接 --> <!-- 步骤1: 生成授权链接 -->
<div class="rounded-lg border border-blue-300 bg-white/80 p-4"> <div
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
>
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<div <div
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white" class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white"
@@ -23,7 +29,9 @@
1 1
</div> </div>
<div class="flex-1"> <div class="flex-1">
<p class="mb-2 font-medium text-blue-900">点击下方按钮生成授权链接</p> <p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
点击下方按钮生成授权链接
</p>
<button <button
v-if="!authUrl" v-if="!authUrl"
class="btn btn-primary px-4 py-2 text-sm" class="btn btn-primary px-4 py-2 text-sm"
@@ -37,13 +45,13 @@
<div v-else class="space-y-3"> <div v-else class="space-y-3">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<input <input
class="form-input flex-1 bg-gray-50 font-mono text-xs" class="form-input flex-1 bg-gray-50 font-mono text-xs dark:bg-gray-700"
readonly readonly
type="text" type="text"
:value="authUrl" :value="authUrl"
/> />
<button <button
class="rounded-lg bg-gray-100 px-3 py-2 transition-colors hover:bg-gray-200" class="rounded-lg bg-gray-100 px-3 py-2 transition-colors hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600"
title="复制链接" title="复制链接"
@click="copyAuthUrl" @click="copyAuthUrl"
> >
@@ -62,7 +70,9 @@
</div> </div>
<!-- 步骤2: 访问链接并授权 --> <!-- 步骤2: 访问链接并授权 -->
<div class="rounded-lg border border-blue-300 bg-white/80 p-4"> <div
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
>
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<div <div
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white" class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white"
@@ -70,12 +80,16 @@
2 2
</div> </div>
<div class="flex-1"> <div class="flex-1">
<p class="mb-2 font-medium text-blue-900">在浏览器中打开链接并完成授权</p> <p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
<p class="mb-2 text-sm text-blue-700"> 在浏览器中打开链接并完成授权
</p>
<p class="mb-2 text-sm text-blue-700 dark:text-blue-300">
请在新标签页中打开授权链接登录您的 Claude 账户并授权 请在新标签页中打开授权链接登录您的 Claude 账户并授权
</p> </p>
<div class="rounded border border-yellow-300 bg-yellow-50 p-3"> <div
<p class="text-xs text-yellow-800"> class="rounded border border-yellow-300 bg-yellow-50 p-3 dark:border-yellow-700 dark:bg-yellow-900/30"
>
<p class="text-xs text-yellow-800 dark:text-yellow-300">
<i class="fas fa-exclamation-triangle mr-1" /> <i class="fas fa-exclamation-triangle mr-1" />
<strong>注意</strong <strong>注意</strong
>如果您设置了代理请确保浏览器也使用相同的代理访问授权页面 >如果您设置了代理请确保浏览器也使用相同的代理访问授权页面
@@ -86,7 +100,9 @@
</div> </div>
<!-- 步骤3: 输入授权码 --> <!-- 步骤3: 输入授权码 -->
<div class="rounded-lg border border-blue-300 bg-white/80 p-4"> <div
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
>
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<div <div
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white" class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white"
@@ -94,14 +110,18 @@
3 3
</div> </div>
<div class="flex-1"> <div class="flex-1">
<p class="mb-2 font-medium text-blue-900">输入 Authorization Code</p> <p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
<p class="mb-3 text-sm text-blue-700"> 输入 Authorization Code
</p>
<p class="mb-3 text-sm text-blue-700 dark:text-blue-300">
授权完成后页面会显示一个 授权完成后页面会显示一个
<strong>Authorization Code</strong>请将其复制并粘贴到下方输入框 <strong>Authorization Code</strong>请将其复制并粘贴到下方输入框
</p> </p>
<div class="space-y-3"> <div class="space-y-3">
<div> <div>
<label class="mb-2 block text-sm font-semibold text-gray-700"> <label
class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>
<i class="fas fa-key mr-2 text-blue-500" />Authorization Code <i class="fas fa-key mr-2 text-blue-500" />Authorization Code
</label> </label>
<textarea <textarea
@@ -111,7 +131,7 @@
rows="3" rows="3"
/> />
</div> </div>
<p class="mt-2 text-xs text-gray-500"> <p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
<i class="fas fa-info-circle mr-1" /> <i class="fas fa-info-circle mr-1" />
请粘贴从Claude页面复制的Authorization Code 请粘贴从Claude页面复制的Authorization Code
</p> </p>
@@ -127,7 +147,9 @@
<!-- Gemini OAuth流程 --> <!-- Gemini OAuth流程 -->
<div v-else-if="platform === 'gemini'"> <div v-else-if="platform === 'gemini'">
<div class="rounded-lg border border-green-200 bg-green-50 p-6"> <div
class="rounded-lg border border-green-200 bg-green-50 p-6 dark:border-green-700 dark:bg-green-900/30"
>
<div class="flex items-start gap-4"> <div class="flex items-start gap-4">
<div <div
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-green-500" class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-green-500"
@@ -135,12 +157,16 @@
<i class="fas fa-robot text-white" /> <i class="fas fa-robot text-white" />
</div> </div>
<div class="flex-1"> <div class="flex-1">
<h4 class="mb-3 font-semibold text-green-900">Gemini 账户授权</h4> <h4 class="mb-3 font-semibold text-green-900 dark:text-green-200">Gemini 账户授权</h4>
<p class="mb-4 text-sm text-green-800">请按照以下步骤完成 Gemini 账户的授权</p> <p class="mb-4 text-sm text-green-800 dark:text-green-300">
请按照以下步骤完成 Gemini 账户的授权
</p>
<div class="space-y-4"> <div class="space-y-4">
<!-- 步骤1: 生成授权链接 --> <!-- 步骤1: 生成授权链接 -->
<div class="rounded-lg border border-green-300 bg-white/80 p-4"> <div
class="rounded-lg border border-green-300 bg-white/80 p-4 dark:border-green-600 dark:bg-gray-800/80"
>
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<div <div
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-green-600 text-xs font-bold text-white" class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-green-600 text-xs font-bold text-white"
@@ -148,7 +174,9 @@
1 1
</div> </div>
<div class="flex-1"> <div class="flex-1">
<p class="mb-2 font-medium text-green-900">点击下方按钮生成授权链接</p> <p class="mb-2 font-medium text-green-900 dark:text-green-200">
点击下方按钮生成授权链接
</p>
<button <button
v-if="!authUrl" v-if="!authUrl"
class="btn btn-primary px-4 py-2 text-sm" class="btn btn-primary px-4 py-2 text-sm"
@@ -162,13 +190,13 @@
<div v-else class="space-y-3"> <div v-else class="space-y-3">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<input <input
class="form-input flex-1 bg-gray-50 font-mono text-xs" class="form-input flex-1 bg-gray-50 font-mono text-xs dark:bg-gray-700"
readonly readonly
type="text" type="text"
:value="authUrl" :value="authUrl"
/> />
<button <button
class="rounded-lg bg-gray-100 px-3 py-2 transition-colors hover:bg-gray-200" class="rounded-lg bg-gray-100 px-3 py-2 transition-colors hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600"
title="复制链接" title="复制链接"
@click="copyAuthUrl" @click="copyAuthUrl"
> >
@@ -187,7 +215,9 @@
</div> </div>
<!-- 步骤2: 操作说明 --> <!-- 步骤2: 操作说明 -->
<div class="rounded-lg border border-green-300 bg-white/80 p-4"> <div
class="rounded-lg border border-green-300 bg-white/80 p-4 dark:border-green-600 dark:bg-gray-800/80"
>
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<div <div
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-green-600 text-xs font-bold text-white" class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-green-600 text-xs font-bold text-white"
@@ -195,12 +225,16 @@
2 2
</div> </div>
<div class="flex-1"> <div class="flex-1">
<p class="mb-2 font-medium text-blue-900">在浏览器中打开链接并完成授权</p> <p class="mb-2 font-medium text-green-900 dark:text-green-200">
<p class="mb-2 text-sm text-blue-700"> 在浏览器中打开链接并完成授权
</p>
<p class="mb-2 text-sm text-green-700 dark:text-green-300">
请在新标签页中打开授权链接登录您的 Gemini 账户并授权 请在新标签页中打开授权链接登录您的 Gemini 账户并授权
</p> </p>
<div class="rounded border border-yellow-300 bg-yellow-50 p-3"> <div
<p class="text-xs text-yellow-800"> class="rounded border border-yellow-300 bg-yellow-50 p-3 dark:border-yellow-700 dark:bg-yellow-900/30"
>
<p class="text-xs text-yellow-800 dark:text-yellow-300">
<i class="fas fa-exclamation-triangle mr-1" /> <i class="fas fa-exclamation-triangle mr-1" />
<strong>注意</strong <strong>注意</strong
>如果您设置了代理请确保浏览器也使用相同的代理访问授权页面 >如果您设置了代理请确保浏览器也使用相同的代理访问授权页面
@@ -211,7 +245,9 @@
</div> </div>
<!-- 步骤3: 输入授权码 --> <!-- 步骤3: 输入授权码 -->
<div class="rounded-lg border border-green-300 bg-white/80 p-4"> <div
class="rounded-lg border border-green-300 bg-white/80 p-4 dark:border-green-600 dark:bg-gray-800/80"
>
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<div <div
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-green-600 text-xs font-bold text-white" class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-green-600 text-xs font-bold text-white"
@@ -219,13 +255,17 @@
3 3
</div> </div>
<div class="flex-1"> <div class="flex-1">
<p class="mb-2 font-medium text-green-900">输入 Authorization Code</p> <p class="mb-2 font-medium text-green-900 dark:text-green-200">
<p class="mb-3 text-sm text-green-700"> 输入 Authorization Code
</p>
<p class="mb-3 text-sm text-green-700 dark:text-green-300">
授权完成后页面会显示一个 Authorization Code请将其复制并粘贴到下方输入框 授权完成后页面会显示一个 Authorization Code请将其复制并粘贴到下方输入框
</p> </p>
<div class="space-y-3"> <div class="space-y-3">
<div> <div>
<label class="mb-2 block text-sm font-semibold text-gray-700"> <label
class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>
<i class="fas fa-key mr-2 text-green-500" />Authorization Code <i class="fas fa-key mr-2 text-green-500" />Authorization Code
</label> </label>
<textarea <textarea
@@ -236,7 +276,7 @@
/> />
</div> </div>
<div class="mt-2 space-y-1"> <div class="mt-2 space-y-1">
<p class="text-xs text-gray-600"> <p class="text-xs text-gray-600 dark:text-gray-400">
<i class="fas fa-check-circle mr-1 text-green-500" /> <i class="fas fa-check-circle mr-1 text-green-500" />
请粘贴从Gemini页面复制的Authorization Code 请粘贴从Gemini页面复制的Authorization Code
</p> </p>
@@ -253,7 +293,9 @@
<!-- OpenAI OAuth流程 --> <!-- OpenAI OAuth流程 -->
<div v-else-if="platform === 'openai'"> <div v-else-if="platform === 'openai'">
<div class="rounded-lg border border-orange-200 bg-orange-50 p-6"> <div
class="rounded-lg border border-orange-200 bg-orange-50 p-6 dark:border-orange-700 dark:bg-orange-900/30"
>
<div class="flex items-start gap-4"> <div class="flex items-start gap-4">
<div <div
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-orange-500" class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-orange-500"
@@ -261,12 +303,16 @@
<i class="fas fa-brain text-white" /> <i class="fas fa-brain text-white" />
</div> </div>
<div class="flex-1"> <div class="flex-1">
<h4 class="mb-3 font-semibold text-orange-900">OpenAI 账户授权</h4> <h4 class="mb-3 font-semibold text-orange-900 dark:text-orange-200">OpenAI 账户授权</h4>
<p class="mb-4 text-sm text-orange-800">请按照以下步骤完成 OpenAI 账户的授权</p> <p class="mb-4 text-sm text-orange-800 dark:text-orange-300">
请按照以下步骤完成 OpenAI 账户的授权
</p>
<div class="space-y-4"> <div class="space-y-4">
<!-- 步骤1: 生成授权链接 --> <!-- 步骤1: 生成授权链接 -->
<div class="rounded-lg border border-orange-300 bg-white/80 p-4"> <div
class="rounded-lg border border-orange-300 bg-white/80 p-4 dark:border-orange-600 dark:bg-gray-800/80"
>
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<div <div
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-orange-600 text-xs font-bold text-white" class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-orange-600 text-xs font-bold text-white"
@@ -274,7 +320,9 @@
1 1
</div> </div>
<div class="flex-1"> <div class="flex-1">
<p class="mb-2 font-medium text-orange-900">点击下方按钮生成授权链接</p> <p class="mb-2 font-medium text-orange-900 dark:text-orange-200">
点击下方按钮生成授权链接
</p>
<button <button
v-if="!authUrl" v-if="!authUrl"
class="btn btn-primary px-4 py-2 text-sm" class="btn btn-primary px-4 py-2 text-sm"
@@ -288,13 +336,13 @@
<div v-else class="space-y-3"> <div v-else class="space-y-3">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<input <input
class="form-input flex-1 bg-gray-50 font-mono text-xs" class="form-input flex-1 bg-gray-50 font-mono text-xs dark:bg-gray-700"
readonly readonly
type="text" type="text"
:value="authUrl" :value="authUrl"
/> />
<button <button
class="rounded-lg bg-gray-100 px-3 py-2 transition-colors hover:bg-gray-200" class="rounded-lg bg-gray-100 px-3 py-2 transition-colors hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600"
title="复制链接" title="复制链接"
@click="copyAuthUrl" @click="copyAuthUrl"
> >
@@ -313,7 +361,9 @@
</div> </div>
<!-- 步骤2: 访问链接并授权 --> <!-- 步骤2: 访问链接并授权 -->
<div class="rounded-lg border border-orange-300 bg-white/80 p-4"> <div
class="rounded-lg border border-orange-300 bg-white/80 p-4 dark:border-orange-600 dark:bg-gray-800/80"
>
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<div <div
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-orange-600 text-xs font-bold text-white" class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-orange-600 text-xs font-bold text-white"
@@ -321,23 +371,29 @@
2 2
</div> </div>
<div class="flex-1"> <div class="flex-1">
<p class="mb-2 font-medium text-orange-900">在浏览器中打开链接并完成授权</p> <p class="mb-2 font-medium text-orange-900 dark:text-orange-200">
<p class="mb-2 text-sm text-orange-700"> 在浏览器中打开链接并完成授权
</p>
<p class="mb-2 text-sm text-orange-700 dark:text-orange-300">
请在新标签页中打开授权链接登录您的 OpenAI 账户并授权 请在新标签页中打开授权链接登录您的 OpenAI 账户并授权
</p> </p>
<div class="mb-3 rounded border border-amber-300 bg-amber-50 p-3"> <div
<p class="text-xs text-amber-800"> class="mb-3 rounded border border-amber-300 bg-amber-50 p-3 dark:border-amber-700 dark:bg-amber-900/30"
>
<p class="text-xs text-amber-800 dark:text-amber-300">
<i class="fas fa-clock mr-1" /> <i class="fas fa-clock mr-1" />
<strong>重要提示</strong>授权后页面可能会加载较长时间请耐心等待 <strong>重要提示</strong>授权后页面可能会加载较长时间请耐心等待
</p> </p>
<p class="mt-2 text-xs text-amber-700"> <p class="mt-2 text-xs text-amber-700 dark:text-amber-400">
当浏览器地址栏变为 当浏览器地址栏变为
<strong class="font-mono">http://localhost:1455/...</strong> <strong class="font-mono">http://localhost:1455/...</strong>
开头时表示授权已完成 开头时表示授权已完成
</p> </p>
</div> </div>
<div class="rounded border border-yellow-300 bg-yellow-50 p-3"> <div
<p class="text-xs text-yellow-800"> class="rounded border border-yellow-300 bg-yellow-50 p-3 dark:border-yellow-700 dark:bg-yellow-900/30"
>
<p class="text-xs text-yellow-800 dark:text-yellow-300">
<i class="fas fa-exclamation-triangle mr-1" /> <i class="fas fa-exclamation-triangle mr-1" />
<strong>注意</strong <strong>注意</strong
>如果您设置了代理请确保浏览器也使用相同的代理访问授权页面 >如果您设置了代理请确保浏览器也使用相同的代理访问授权页面
@@ -348,7 +404,9 @@
</div> </div>
<!-- 步骤3: 输入授权码 --> <!-- 步骤3: 输入授权码 -->
<div class="rounded-lg border border-orange-300 bg-white/80 p-4"> <div
class="rounded-lg border border-orange-300 bg-white/80 p-4 dark:border-orange-600 dark:bg-gray-800/80"
>
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<div <div
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-orange-600 text-xs font-bold text-white" class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-orange-600 text-xs font-bold text-white"
@@ -356,14 +414,18 @@
3 3
</div> </div>
<div class="flex-1"> <div class="flex-1">
<p class="mb-2 font-medium text-orange-900">输入授权链接或 Code</p> <p class="mb-2 font-medium text-orange-900 dark:text-orange-200">
<p class="mb-3 text-sm text-orange-700"> 输入授权链接或 Code
</p>
<p class="mb-3 text-sm text-orange-700 dark:text-orange-300">
授权完成后当页面地址变为 授权完成后当页面地址变为
<strong class="font-mono">http://localhost:1455/...</strong> 时: <strong class="font-mono">http://localhost:1455/...</strong> 时:
</p> </p>
<div class="space-y-3"> <div class="space-y-3">
<div> <div>
<label class="mb-2 block text-sm font-semibold text-gray-700"> <label
class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>
<i class="fas fa-link mr-2 text-orange-500" />授权链接或 Code <i class="fas fa-link mr-2 text-orange-500" />授权链接或 Code
</label> </label>
<textarea <textarea
@@ -373,13 +435,15 @@
rows="3" rows="3"
/> />
</div> </div>
<div class="rounded border border-blue-300 bg-blue-50 p-2"> <div
<p class="text-xs text-blue-700"> class="rounded border border-blue-300 bg-blue-50 p-2 dark:border-blue-700 dark:bg-blue-900/30"
>
<p class="text-xs text-blue-700 dark:text-blue-300">
<i class="fas fa-lightbulb mr-1" /> <i class="fas fa-lightbulb mr-1" />
<strong>提示</strong>您可以直接复制整个链接或仅复制 code <strong>提示</strong>您可以直接复制整个链接或仅复制 code
参数值系统会自动识别 参数值系统会自动识别
</p> </p>
<p class="mt-1 text-xs text-blue-600"> <p class="mt-1 text-xs text-blue-600 dark:text-blue-400">
完整链接示例<span class="font-mono" 完整链接示例<span class="font-mono"
>http://localhost:1455/auth/callback?code=ac_4hm8...</span >http://localhost:1455/auth/callback?code=ac_4hm8...</span
> >
@@ -402,7 +466,7 @@
<div class="flex gap-3 pt-4"> <div class="flex gap-3 pt-4">
<button <button
class="flex-1 rounded-xl bg-gray-100 px-6 py-3 font-semibold text-gray-700 transition-colors hover:bg-gray-200" class="flex-1 rounded-xl bg-gray-100 px-6 py-3 font-semibold text-gray-700 transition-colors hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
type="button" type="button"
@click="$emit('back')" @click="$emit('back')"
> >

View File

@@ -1,35 +1,43 @@
<template> <template>
<div class="space-y-4"> <div class="space-y-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h4 class="text-sm font-semibold text-gray-700">代理设置 (可选)</h4> <h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300">代理设置 (可选)</h4>
<label class="flex cursor-pointer items-center"> <label class="flex cursor-pointer items-center">
<input <input
v-model="proxy.enabled" v-model="proxy.enabled"
class="h-4 w-4 rounded border-gray-300 bg-gray-100 text-blue-600 focus:ring-blue-500" class="h-4 w-4 rounded border-gray-300 bg-gray-100 text-blue-600 focus:ring-blue-500"
type="checkbox" type="checkbox"
/> />
<span class="ml-2 text-sm text-gray-700">启用代理</span> <span class="ml-2 text-sm text-gray-700 dark:text-gray-300">启用代理</span>
</label> </label>
</div> </div>
<div v-if="proxy.enabled" class="space-y-4 rounded-lg border border-gray-200 bg-gray-50 p-4"> <div
v-if="proxy.enabled"
class="space-y-4 rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-600 dark:bg-gray-800"
>
<div class="mb-3 flex items-start gap-3"> <div class="mb-3 flex items-start gap-3">
<div class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-gray-500"> <div class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-gray-500">
<i class="fas fa-server text-sm text-white" /> <i class="fas fa-server text-sm text-white" />
</div> </div>
<div class="flex-1"> <div class="flex-1">
<p class="text-sm text-gray-700"> <p class="text-sm text-gray-700 dark:text-gray-300">
配置代理以访问受限的网络资源支持 SOCKS5 HTTP 代理 配置代理以访问受限的网络资源支持 SOCKS5 HTTP 代理
</p> </p>
<p class="mt-1 text-xs text-gray-500"> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
请确保代理服务器稳定可用否则会影响账户的正常使用 请确保代理服务器稳定可用否则会影响账户的正常使用
</p> </p>
</div> </div>
</div> </div>
<div> <div>
<label class="mb-2 block text-sm font-medium text-gray-700">代理类型</label> <label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
<select v-model="proxy.type" class="form-input w-full"> >代理类型</label
>
<select
v-model="proxy.type"
class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
>
<option value="socks5">SOCKS5</option> <option value="socks5">SOCKS5</option>
<option value="http">HTTP</option> <option value="http">HTTP</option>
<option value="https">HTTPS</option> <option value="https">HTTPS</option>
@@ -38,19 +46,23 @@
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div> <div>
<label class="mb-2 block text-sm font-medium text-gray-700">主机地址</label> <label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>主机地址</label
>
<input <input
v-model="proxy.host" v-model="proxy.host"
class="form-input w-full" class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
placeholder="例如: 192.168.1.100" placeholder="例如: 192.168.1.100"
type="text" type="text"
/> />
</div> </div>
<div> <div>
<label class="mb-2 block text-sm font-medium text-gray-700">端口</label> <label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>端口</label
>
<input <input
v-model="proxy.port" v-model="proxy.port"
class="form-input w-full" class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
placeholder="例如: 1080" placeholder="例如: 1080"
type="number" type="number"
/> />
@@ -65,32 +77,39 @@
class="h-4 w-4 rounded border-gray-300 bg-gray-100 text-blue-600 focus:ring-blue-500" class="h-4 w-4 rounded border-gray-300 bg-gray-100 text-blue-600 focus:ring-blue-500"
type="checkbox" type="checkbox"
/> />
<label class="ml-2 cursor-pointer text-sm text-gray-700" for="proxyAuth"> <label
class="ml-2 cursor-pointer text-sm text-gray-700 dark:text-gray-300"
for="proxyAuth"
>
需要身份验证 需要身份验证
</label> </label>
</div> </div>
<div v-if="showAuth" class="grid grid-cols-2 gap-4"> <div v-if="showAuth" class="grid grid-cols-2 gap-4">
<div> <div>
<label class="mb-2 block text-sm font-medium text-gray-700">用户名</label> <label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>用户名</label
>
<input <input
v-model="proxy.username" v-model="proxy.username"
class="form-input w-full" class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
placeholder="代理用户名" placeholder="代理用户名"
type="text" type="text"
/> />
</div> </div>
<div> <div>
<label class="mb-2 block text-sm font-medium text-gray-700">密码</label> <label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>密码</label
>
<div class="relative"> <div class="relative">
<input <input
v-model="proxy.password" v-model="proxy.password"
class="form-input w-full pr-10" class="form-input w-full pr-10 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
placeholder="代理密码" placeholder="代理密码"
:type="showPassword ? 'text' : 'password'" :type="showPassword ? 'text' : 'password'"
/> />
<button <button
class="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600" class="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-400"
type="button" type="button"
@click="showPassword = !showPassword" @click="showPassword = !showPassword"
> >
@@ -101,8 +120,10 @@
</div> </div>
</div> </div>
<div class="rounded-lg border border-blue-200 bg-blue-50 p-3"> <div
<p class="text-xs text-blue-700"> class="rounded-lg border border-blue-200 bg-blue-50 p-3 dark:border-blue-700 dark:bg-blue-900/30"
>
<p class="text-xs text-blue-700 dark:text-blue-300">
<i class="fas fa-info-circle mr-1" /> <i class="fas fa-info-circle mr-1" />
<strong>提示</strong <strong>提示</strong
>代理设置将用于所有与此账户相关的API请求请确保代理服务器支持HTTPS流量转发 >代理设置将用于所有与此账户相关的API请求请确保代理服务器支持HTTPS流量转发

View File

@@ -0,0 +1,745 @@
<template>
<Teleport to="body">
<div class="modal fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-4">
<div
class="modal-content mx-auto flex max-h-[90vh] w-full max-w-4xl flex-col p-4 sm:p-6 md:p-8"
>
<div class="mb-4 flex items-center justify-between sm:mb-6">
<div class="flex items-center gap-2 sm:gap-3">
<div
class="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-blue-500 to-blue-600 sm:h-10 sm:w-10 sm:rounded-xl"
>
<i class="fas fa-edit text-sm text-white sm:text-base" />
</div>
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100 sm:text-xl">
批量编辑 API Keys ({{ selectedCount }} )
</h3>
</div>
<button
class="p-1 text-gray-400 transition-colors hover:text-gray-600 dark:hover:text-gray-300"
@click="$emit('close')"
>
<i class="fas fa-times text-lg sm:text-xl" />
</button>
</div>
<form
class="modal-scroll-content custom-scrollbar flex-1 space-y-4 sm:space-y-6"
@submit.prevent="batchUpdateApiKeys"
>
<!-- 说明文本 -->
<div class="rounded-lg bg-blue-50 p-4 dark:bg-blue-900/20">
<div class="flex items-start gap-3">
<i class="fas fa-info-circle mt-1 text-blue-500" />
<div>
<p class="text-sm font-medium text-blue-800 dark:text-blue-300">批量编辑说明</p>
<p class="mt-1 text-sm text-blue-700 dark:text-blue-400">
以下设置将应用到所选的 {{ selectedCount }} API
Key只有填写或修改的字段才会被更新空白字段将保持原值不变
</p>
</div>
</div>
</div>
<!-- 标签编辑 -->
<div>
<label
class="mb-1.5 block text-xs font-semibold text-gray-700 dark:text-gray-300 sm:mb-3 sm:text-sm"
>
标签 (批量操作)
</label>
<div class="space-y-4">
<!-- 标签操作模式选择 -->
<div class="flex flex-wrap gap-4">
<label class="flex cursor-pointer items-center">
<input v-model="tagOperation" class="mr-2" type="radio" value="replace" />
<span class="text-sm text-gray-700 dark:text-gray-300">替换标签</span>
</label>
<label class="flex cursor-pointer items-center">
<input v-model="tagOperation" class="mr-2" type="radio" value="add" />
<span class="text-sm text-gray-700 dark:text-gray-300">添加标签</span>
</label>
<label class="flex cursor-pointer items-center">
<input v-model="tagOperation" class="mr-2" type="radio" value="remove" />
<span class="text-sm text-gray-700 dark:text-gray-300">移除标签</span>
</label>
<label class="flex cursor-pointer items-center">
<input v-model="tagOperation" class="mr-2" type="radio" value="none" />
<span class="text-sm text-gray-700 dark:text-gray-300">不修改标签</span>
</label>
</div>
<!-- 标签编辑区域 -->
<div v-if="tagOperation !== 'none'" class="space-y-3">
<!-- 已选择的标签 -->
<div v-if="form.tags.length > 0">
<div class="mb-2 text-xs font-medium text-gray-600 dark:text-gray-400">
{{
tagOperation === 'replace'
? '新标签列表:'
: tagOperation === 'add'
? '要添加的标签:'
: '要移除的标签:'
}}
</div>
<div class="flex flex-wrap gap-2">
<span
v-for="(tag, index) in form.tags"
:key="'selected-' + index"
class="inline-flex items-center gap-1 rounded-full bg-blue-100 px-3 py-1 text-sm text-blue-800 dark:bg-blue-900/30 dark:text-blue-300"
>
{{ tag }}
<button
class="ml-1 hover:text-blue-900"
type="button"
@click="removeTag(index)"
>
<i class="fas fa-times text-xs" />
</button>
</span>
</div>
</div>
<!-- 可选择的已有标签 -->
<div v-if="unselectedTags.length > 0">
<div class="mb-2 text-xs font-medium text-gray-600 dark:text-gray-400">
点击选择已有标签:
</div>
<div class="flex flex-wrap gap-2">
<button
v-for="tag in unselectedTags"
:key="'available-' + tag"
class="inline-flex items-center gap-1 rounded-full bg-gray-100 px-3 py-1 text-sm text-gray-700 transition-colors hover:bg-blue-100 hover:text-blue-700 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-blue-900/30 dark:hover:text-blue-300"
type="button"
@click="selectTag(tag)"
>
<i class="fas fa-tag text-xs text-gray-500 dark:text-gray-400" />
{{ tag }}
</button>
</div>
</div>
<!-- 创建新标签 -->
<div>
<div class="mb-2 text-xs font-medium text-gray-600 dark:text-gray-400">
创建新标签:
</div>
<div class="flex gap-2">
<input
v-model="newTag"
class="form-input flex-1 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
placeholder="输入新标签名称"
type="text"
@keypress.enter.prevent="addTag"
/>
<button
class="rounded-lg bg-green-500 px-4 py-2 text-white transition-colors hover:bg-green-600 dark:bg-green-600 dark:hover:bg-green-700"
type="button"
@click="addTag"
>
<i class="fas fa-plus" />
</button>
</div>
</div>
</div>
</div>
</div>
<!-- 速率限制设置 -->
<div
class="rounded-lg border border-blue-200 bg-blue-50 p-3 dark:border-blue-700 dark:bg-blue-900/20"
>
<div class="mb-2 flex items-center gap-2">
<div
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded bg-blue-500"
>
<i class="fas fa-tachometer-alt text-xs text-white" />
</div>
<h4 class="text-sm font-semibold text-gray-800 dark:text-gray-200">速率限制设置</h4>
</div>
<div class="space-y-2">
<div class="grid grid-cols-1 gap-2 lg:grid-cols-3">
<div>
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300">
时间窗口 (分钟)
</label>
<input
v-model="form.rateLimitWindow"
class="form-input w-full text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
min="1"
placeholder="不修改"
type="number"
/>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300"
>请求次数限制</label
>
<input
v-model="form.rateLimitRequests"
class="form-input w-full text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
min="1"
placeholder="不修改"
type="number"
/>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300"
>Token 限制</label
>
<input
v-model="form.tokenLimit"
class="form-input w-full text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
placeholder="不修改"
type="number"
/>
</div>
</div>
</div>
</div>
<!-- 每日费用限制 -->
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">
每日费用限制 (美元)
</label>
<input
v-model="form.dailyCostLimit"
class="form-input w-full dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
min="0"
placeholder="不修改 (0 表示无限制)"
step="0.01"
type="number"
/>
</div>
<!-- 并发限制 -->
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>并发限制</label
>
<input
v-model="form.concurrencyLimit"
class="form-input w-full dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
min="0"
placeholder="不修改 (0 表示无限制)"
type="number"
/>
</div>
<!-- 激活状态 -->
<div>
<div class="mb-3 flex items-center gap-4">
<label class="text-sm font-semibold text-gray-700 dark:text-gray-300">激活状态</label>
<div class="flex gap-4">
<label class="flex cursor-pointer items-center">
<input v-model="form.isActive" class="mr-2" type="radio" :value="true" />
<span class="text-sm text-gray-700 dark:text-gray-300">激活</span>
</label>
<label class="flex cursor-pointer items-center">
<input v-model="form.isActive" class="mr-2" type="radio" :value="false" />
<span class="text-sm text-gray-700 dark:text-gray-300">禁用</span>
</label>
<label class="flex cursor-pointer items-center">
<input v-model="form.isActive" class="mr-2" type="radio" :value="null" />
<span class="text-sm text-gray-700 dark:text-gray-300">不修改</span>
</label>
</div>
</div>
</div>
<!-- 服务权限 -->
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>服务权限</label
>
<div class="flex flex-wrap gap-4">
<label class="flex cursor-pointer items-center">
<input v-model="form.permissions" class="mr-2" type="radio" value="" />
<span class="text-sm text-gray-700">不修改</span>
</label>
<label class="flex cursor-pointer items-center">
<input v-model="form.permissions" class="mr-2" type="radio" value="all" />
<span class="text-sm text-gray-700">全部服务</span>
</label>
<label class="flex cursor-pointer items-center">
<input v-model="form.permissions" class="mr-2" type="radio" value="claude" />
<span class="text-sm text-gray-700"> Claude</span>
</label>
<label class="flex cursor-pointer items-center">
<input v-model="form.permissions" class="mr-2" type="radio" value="gemini" />
<span class="text-sm text-gray-700"> Gemini</span>
</label>
<label class="flex cursor-pointer items-center">
<input v-model="form.permissions" class="mr-2" type="radio" value="openai" />
<span class="text-sm text-gray-700"> OpenAI</span>
</label>
</div>
</div>
<!-- 专属账号绑定 -->
<div>
<div class="mb-3 flex items-center justify-between">
<label class="text-sm font-semibold text-gray-700 dark:text-gray-300"
>专属账号绑定</label
>
<button
class="flex items-center gap-1 text-sm text-blue-600 transition-colors hover:text-blue-800 disabled:cursor-not-allowed disabled:opacity-50 dark:text-blue-400 dark:hover:text-blue-300"
:disabled="accountsLoading"
title="刷新账号列表"
type="button"
@click="refreshAccounts"
>
<i
:class="[
'fas',
accountsLoading ? 'fa-spinner fa-spin' : 'fa-sync-alt',
'text-xs'
]"
/>
<span>{{ accountsLoading ? '刷新中...' : '刷新账号' }}</span>
</button>
</div>
<div class="grid grid-cols-1 gap-3">
<div>
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400"
>Claude 专属账号</label
>
<select
v-model="form.claudeAccountId"
class="form-input w-full dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
:disabled="form.permissions === 'gemini' || form.permissions === 'openai'"
>
<option value="">不修改</option>
<option value="SHARED_POOL">使用共享账号池</option>
<optgroup v-if="localAccounts.claudeGroups.length > 0" label="账号分组">
<option
v-for="group in localAccounts.claudeGroups"
:key="group.id"
:value="`group:${group.id}`"
>
分组 - {{ group.name }}
</option>
</optgroup>
<optgroup v-if="localAccounts.claude.length > 0" label="专属账号">
<option
v-for="account in localAccounts.claude"
:key="account.id"
:value="
account.platform === 'claude-console' ? `console:${account.id}` : account.id
"
>
{{ account.name }} ({{
account.platform === 'claude-console' ? 'Console' : 'OAuth'
}})
</option>
</optgroup>
</select>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400"
>Gemini 专属账号</label
>
<select
v-model="form.geminiAccountId"
class="form-input w-full dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
:disabled="form.permissions === 'claude' || form.permissions === 'openai'"
>
<option value="">不修改</option>
<option value="SHARED_POOL">使用共享账号池</option>
<optgroup v-if="localAccounts.geminiGroups.length > 0" label="账号分组">
<option
v-for="group in localAccounts.geminiGroups"
:key="group.id"
:value="`group:${group.id}`"
>
分组 - {{ group.name }}
</option>
</optgroup>
<optgroup v-if="localAccounts.gemini.length > 0" label="专属账号">
<option
v-for="account in localAccounts.gemini"
:key="account.id"
:value="account.id"
>
{{ account.name }}
</option>
</optgroup>
</select>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400"
>OpenAI 专属账号</label
>
<select
v-model="form.openaiAccountId"
class="form-input w-full dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
:disabled="form.permissions === 'claude' || form.permissions === 'gemini'"
>
<option value="">不修改</option>
<option value="SHARED_POOL">使用共享账号池</option>
<optgroup v-if="localAccounts.openaiGroups.length > 0" label="账号分组">
<option
v-for="group in localAccounts.openaiGroups"
:key="group.id"
:value="`group:${group.id}`"
>
分组 - {{ group.name }}
</option>
</optgroup>
<optgroup v-if="localAccounts.openai.length > 0" label="专属账号">
<option
v-for="account in localAccounts.openai"
:key="account.id"
:value="account.id"
>
{{ account.name }}
</option>
</optgroup>
</select>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400"
>Bedrock 专属账号</label
>
<select
v-model="form.bedrockAccountId"
class="form-input w-full dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
:disabled="form.permissions === 'gemini' || form.permissions === 'openai'"
>
<option value="">不修改</option>
<option value="SHARED_POOL">使用共享账号池</option>
<optgroup v-if="localAccounts.bedrock.length > 0" label="专属账号">
<option
v-for="account in localAccounts.bedrock"
:key="account.id"
:value="account.id"
>
{{ account.name }}
</option>
</optgroup>
</select>
</div>
</div>
</div>
<div class="flex gap-3 pt-4">
<button
class="flex-1 rounded-xl bg-gray-100 px-6 py-3 font-semibold text-gray-700 transition-colors hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="$emit('close')"
>
取消
</button>
<button
class="btn btn-primary flex-1 px-6 py-3 font-semibold"
:disabled="loading"
type="submit"
>
<div v-if="loading" class="loading-spinner mr-2" />
<i v-else class="fas fa-save mr-2" />
{{ loading ? '保存中...' : '批量保存' }}
</button>
</div>
</form>
</div>
</div>
</Teleport>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { showToast } from '@/utils/toast'
import { useApiKeysStore } from '@/stores/apiKeys'
import { apiClient } from '@/config/api'
const props = defineProps({
selectedKeys: {
type: Array,
required: true
},
accounts: {
type: Object,
default: () => ({ claude: [], gemini: [], openai: [], bedrock: [] })
}
})
const emit = defineEmits(['close', 'success'])
const apiKeysStore = useApiKeysStore()
const loading = ref(false)
const accountsLoading = ref(false)
const localAccounts = ref({
claude: [],
gemini: [],
openai: [],
bedrock: [],
claudeGroups: [],
geminiGroups: [],
openaiGroups: []
})
// 标签相关
const newTag = ref('')
const availableTags = ref([])
const tagOperation = ref('none') // 'replace', 'add', 'remove', 'none'
const selectedCount = computed(() => props.selectedKeys.length)
// 计算未选择的标签
const unselectedTags = computed(() => {
return availableTags.value.filter((tag) => !form.tags.includes(tag))
})
// 表单数据
const form = reactive({
tokenLimit: '',
rateLimitWindow: '',
rateLimitRequests: '',
concurrencyLimit: '',
dailyCostLimit: '',
permissions: '', // 空字符串表示不修改
claudeAccountId: '',
geminiAccountId: '',
openaiAccountId: '',
bedrockAccountId: '',
tags: [],
isActive: null // null表示不修改
})
// 标签管理方法
const addTag = () => {
if (newTag.value && newTag.value.trim()) {
const tag = newTag.value.trim()
if (!form.tags.includes(tag)) {
form.tags.push(tag)
}
newTag.value = ''
}
}
const selectTag = (tag) => {
if (!form.tags.includes(tag)) {
form.tags.push(tag)
}
}
const removeTag = (index) => {
form.tags.splice(index, 1)
}
// 刷新账号列表
const refreshAccounts = async () => {
accountsLoading.value = true
try {
const [claudeData, claudeConsoleData, geminiData, openaiData, bedrockData, groupsData] =
await Promise.all([
apiClient.get('/admin/claude-accounts'),
apiClient.get('/admin/claude-console-accounts'),
apiClient.get('/admin/gemini-accounts'),
apiClient.get('/admin/openai-accounts'),
apiClient.get('/admin/bedrock-accounts'),
apiClient.get('/admin/account-groups')
])
// 合并Claude OAuth账户和Claude Console账户
const claudeAccounts = []
if (claudeData.success) {
claudeData.data?.forEach((account) => {
claudeAccounts.push({
...account,
platform: 'claude-oauth',
isDedicated: account.accountType === 'dedicated'
})
})
}
if (claudeConsoleData.success) {
claudeConsoleData.data?.forEach((account) => {
claudeAccounts.push({
...account,
platform: 'claude-console',
isDedicated: account.accountType === 'dedicated'
})
})
}
localAccounts.value.claude = claudeAccounts
if (geminiData.success) {
localAccounts.value.gemini = (geminiData.data || []).map((account) => ({
...account,
isDedicated: account.accountType === 'dedicated'
}))
}
if (openaiData.success) {
localAccounts.value.openai = (openaiData.data || []).map((account) => ({
...account,
isDedicated: account.accountType === 'dedicated'
}))
}
if (bedrockData.success) {
localAccounts.value.bedrock = (bedrockData.data || []).map((account) => ({
...account,
isDedicated: account.accountType === 'dedicated'
}))
}
// 处理分组数据
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')
localAccounts.value.openaiGroups = allGroups.filter((g) => g.platform === 'openai')
}
showToast('账号列表已刷新', 'success')
} catch (error) {
showToast('刷新账号列表失败', 'error')
} finally {
accountsLoading.value = false
}
}
// 批量更新API Keys
const batchUpdateApiKeys = async () => {
loading.value = true
try {
// 准备提交的数据
const updates = {}
// 只有非空值才添加到更新对象中
if (form.tokenLimit !== '' && form.tokenLimit !== null) {
updates.tokenLimit = parseInt(form.tokenLimit)
}
if (form.rateLimitWindow !== '' && form.rateLimitWindow !== null) {
updates.rateLimitWindow = parseInt(form.rateLimitWindow)
}
if (form.rateLimitRequests !== '' && form.rateLimitRequests !== null) {
updates.rateLimitRequests = parseInt(form.rateLimitRequests)
}
if (form.concurrencyLimit !== '' && form.concurrencyLimit !== null) {
updates.concurrencyLimit = parseInt(form.concurrencyLimit)
}
if (form.dailyCostLimit !== '' && form.dailyCostLimit !== null) {
updates.dailyCostLimit = parseFloat(form.dailyCostLimit)
}
// 权限设置
if (form.permissions !== '') {
updates.permissions = form.permissions
}
// 账户绑定
if (form.claudeAccountId !== '') {
if (form.claudeAccountId === 'SHARED_POOL') {
updates.claudeAccountId = null
updates.claudeConsoleAccountId = null
} else if (form.claudeAccountId.startsWith('console:')) {
updates.claudeConsoleAccountId = form.claudeAccountId.substring(8)
updates.claudeAccountId = null
} else if (!form.claudeAccountId.startsWith('group:')) {
updates.claudeAccountId = form.claudeAccountId
updates.claudeConsoleAccountId = null
} else {
updates.claudeAccountId = form.claudeAccountId
updates.claudeConsoleAccountId = null
}
}
if (form.geminiAccountId !== '') {
if (form.geminiAccountId === 'SHARED_POOL') {
updates.geminiAccountId = null
} else {
updates.geminiAccountId = form.geminiAccountId
}
}
if (form.openaiAccountId !== '') {
if (form.openaiAccountId === 'SHARED_POOL') {
updates.openaiAccountId = null
} else {
updates.openaiAccountId = form.openaiAccountId
}
}
if (form.bedrockAccountId !== '') {
if (form.bedrockAccountId === 'SHARED_POOL') {
updates.bedrockAccountId = null
} else {
updates.bedrockAccountId = form.bedrockAccountId
}
}
// 激活状态
if (form.isActive !== null) {
updates.isActive = form.isActive
}
// 标签处理
if (tagOperation.value !== 'none') {
updates.tags = form.tags
updates.tagOperation = tagOperation.value
}
const result = await apiClient.put('/admin/api-keys/batch', {
keyIds: props.selectedKeys,
updates
})
if (result.success) {
const { successCount, failedCount, errors } = result.data
if (successCount > 0) {
showToast(`成功批量编辑 ${successCount} 个 API Keys`, 'success')
if (failedCount > 0) {
const errorMessages = errors.map((e) => `${e.keyId}: ${e.error}`).join('\n')
showToast(`${failedCount} 个编辑失败:\n${errorMessages}`, 'warning')
}
} else {
showToast('所有 API Keys 编辑失败', 'error')
}
emit('success')
emit('close')
} else {
showToast(result.message || '批量编辑失败', 'error')
}
} catch (error) {
showToast('批量编辑失败', 'error')
console.error('批量编辑 API Keys 失败:', error)
} finally {
loading.value = false
}
}
onMounted(async () => {
// 加载已存在的标签
availableTags.value = await apiKeysStore.fetchTags()
// 初始化账号数据
if (props.accounts) {
localAccounts.value = {
claude: props.accounts.claude || [],
gemini: props.accounts.gemini || [],
openai: props.accounts.openai || [],
bedrock: props.accounts.bedrock || [],
claudeGroups: props.accounts.claudeGroups || [],
geminiGroups: props.accounts.geminiGroups || [],
openaiGroups: props.accounts.openaiGroups || []
}
}
})
</script>
<style scoped>
/* 表单样式由全局样式提供 */
</style>

View File

@@ -9,10 +9,12 @@
> >
<i class="fas fa-key text-sm text-white sm:text-base" /> <i class="fas fa-key text-sm text-white sm:text-base" />
</div> </div>
<h3 class="text-lg font-bold text-gray-900 sm:text-xl">创建新的 API Key</h3> <h3 class="text-lg font-bold text-gray-900 dark:text-gray-100 sm:text-xl">
创建新的 API Key
</h3>
</div> </div>
<button <button
class="p-1 text-gray-400 transition-colors hover:text-gray-600" class="p-1 text-gray-400 transition-colors hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
@click="$emit('close')" @click="$emit('close')"
> >
<i class="fas fa-times text-lg sm:text-xl" /> <i class="fas fa-times text-lg sm:text-xl" />
@@ -25,7 +27,7 @@
> >
<!-- 创建类型选择 --> <!-- 创建类型选择 -->
<div <div
class="rounded-lg border border-blue-200 bg-gradient-to-r from-blue-50 to-indigo-50 p-3 sm:p-4" class="rounded-lg border border-blue-200 bg-gradient-to-r from-blue-50 to-indigo-50 p-3 dark:border-blue-700 dark:from-blue-900/20 dark:to-indigo-900/20 sm:p-4"
> >
<div <div
:class="[ :class="[
@@ -33,18 +35,21 @@
form.createType === 'batch' ? 'mb-3' : '' form.createType === 'batch' ? 'mb-3' : ''
]" ]"
> >
<label class="flex h-full items-center text-xs font-semibold text-gray-700 sm:text-sm" <label
class="flex h-full items-center text-xs font-semibold text-gray-700 dark:text-gray-300 sm:text-sm"
>创建类型</label >创建类型</label
> >
<div class="flex items-center gap-3 sm:gap-4"> <div class="flex items-center gap-3 sm:gap-4">
<label class="flex cursor-pointer items-center"> <label class="flex cursor-pointer items-center">
<input <input
v-model="form.createType" v-model="form.createType"
class="mr-1.5 text-blue-600 sm:mr-2" class="mr-1.5 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 sm:mr-2"
type="radio" type="radio"
value="single" value="single"
/> />
<span class="flex items-center text-xs text-gray-700 sm:text-sm"> <span
class="flex items-center text-xs text-gray-700 dark:text-gray-300 sm:text-sm"
>
<i class="fas fa-key mr-1 text-xs" /> <i class="fas fa-key mr-1 text-xs" />
单个创建 单个创建
</span> </span>
@@ -52,11 +57,13 @@
<label class="flex cursor-pointer items-center"> <label class="flex cursor-pointer items-center">
<input <input
v-model="form.createType" v-model="form.createType"
class="mr-1.5 text-blue-600 sm:mr-2" class="mr-1.5 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 sm:mr-2"
type="radio" type="radio"
value="batch" value="batch"
/> />
<span class="flex items-center text-xs text-gray-700 sm:text-sm"> <span
class="flex items-center text-xs text-gray-700 dark:text-gray-300 sm:text-sm"
>
<i class="fas fa-layer-group mr-1 text-xs" /> <i class="fas fa-layer-group mr-1 text-xs" />
批量创建 批量创建
</span> </span>
@@ -68,22 +75,26 @@
<div v-if="form.createType === 'batch'" class="mt-3"> <div v-if="form.createType === 'batch'" class="mt-3">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<div class="flex-1"> <div class="flex-1">
<label class="mb-1 block text-xs font-medium text-gray-600">创建数量</label> <label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400"
>创建数量</label
>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<input <input
v-model.number="form.batchCount" v-model.number="form.batchCount"
class="form-input w-full text-sm" class="form-input w-full text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
max="500" max="500"
min="2" min="2"
placeholder="输入数量 (2-500)" placeholder="输入数量 (2-500)"
required required
type="number" type="number"
/> />
<div class="whitespace-nowrap text-xs text-gray-500">最大支持 500 </div> <div class="whitespace-nowrap text-xs text-gray-500 dark:text-gray-400">
最大支持 500
</div> </div>
</div> </div>
</div> </div>
<p class="mt-2 flex items-start text-xs text-amber-600"> </div>
<p class="mt-2 flex items-start text-xs text-amber-600 dark:text-amber-400">
<i class="fas fa-info-circle mr-1 mt-0.5 flex-shrink-0" /> <i class="fas fa-info-circle mr-1 mt-0.5 flex-shrink-0" />
<span <span
>批量创建时每个 Key 的名称会自动添加序号后缀例如{{ >批量创建时每个 Key 的名称会自动添加序号后缀例如{{
@@ -95,12 +106,13 @@
</div> </div>
<div> <div>
<label class="mb-1.5 block text-xs font-semibold text-gray-700 sm:mb-2 sm:text-sm" <label
class="mb-1.5 block text-xs font-semibold text-gray-700 dark:text-gray-300 sm:mb-2 sm:text-sm"
>名称 <span class="text-red-500">*</span></label >名称 <span class="text-red-500">*</span></label
> >
<input <input
v-model="form.name" v-model="form.name"
class="form-input w-full text-sm" class="form-input w-full text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
:class="{ 'border-red-500': errors.name }" :class="{ 'border-red-500': errors.name }"
:placeholder=" :placeholder="
form.createType === 'batch' form.createType === 'batch'
@@ -111,27 +123,31 @@
type="text" type="text"
@input="errors.name = ''" @input="errors.name = ''"
/> />
<p v-if="errors.name" class="mt-1 text-xs text-red-500"> <p v-if="errors.name" class="mt-1 text-xs text-red-500 dark:text-red-400">
{{ errors.name }} {{ errors.name }}
</p> </p>
</div> </div>
<!-- 标签 --> <!-- 标签 -->
<div> <div>
<label class="mb-2 block text-sm font-semibold text-gray-700">标签</label> <label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>标签</label
>
<div class="space-y-4"> <div class="space-y-4">
<!-- 已选择的标签 --> <!-- 已选择的标签 -->
<div v-if="form.tags.length > 0"> <div v-if="form.tags.length > 0">
<div class="mb-2 text-xs font-medium text-gray-600">已选择的标签:</div> <div class="mb-2 text-xs font-medium text-gray-600 dark:text-gray-400">
已选择的标签:
</div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<span <span
v-for="(tag, index) in form.tags" v-for="(tag, index) in form.tags"
:key="'selected-' + index" :key="'selected-' + index"
class="inline-flex items-center gap-1 rounded-full bg-blue-100 px-3 py-1 text-sm text-blue-800" class="inline-flex items-center gap-1 rounded-full bg-blue-100 px-3 py-1 text-sm text-blue-800 dark:bg-blue-900/30 dark:text-blue-400"
> >
{{ tag }} {{ tag }}
<button <button
class="ml-1 hover:text-blue-900" class="ml-1 hover:text-blue-900 dark:hover:text-blue-300"
type="button" type="button"
@click="removeTag(index)" @click="removeTag(index)"
> >
@@ -143,16 +159,18 @@
<!-- 可选择的已有标签 --> <!-- 可选择的已有标签 -->
<div v-if="unselectedTags.length > 0"> <div v-if="unselectedTags.length > 0">
<div class="mb-2 text-xs font-medium text-gray-600">点击选择已有标签:</div> <div class="mb-2 text-xs font-medium text-gray-600 dark:text-gray-400">
点击选择已有标签:
</div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<button <button
v-for="tag in unselectedTags" v-for="tag in unselectedTags"
:key="'available-' + tag" :key="'available-' + tag"
class="inline-flex items-center gap-1 rounded-full bg-gray-100 px-3 py-1 text-sm text-gray-700 transition-colors hover:bg-blue-100 hover:text-blue-700" class="inline-flex items-center gap-1 rounded-full bg-gray-100 px-3 py-1 text-sm text-gray-700 transition-colors hover:bg-blue-100 hover:text-blue-700 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-blue-900/30 dark:hover:text-blue-400"
type="button" type="button"
@click="selectTag(tag)" @click="selectTag(tag)"
> >
<i class="fas fa-tag text-xs text-gray-500" /> <i class="fas fa-tag text-xs text-gray-500 dark:text-gray-400" />
{{ tag }} {{ tag }}
</button> </button>
</div> </div>
@@ -160,11 +178,13 @@
<!-- 创建新标签 --> <!-- 创建新标签 -->
<div> <div>
<div class="mb-2 text-xs font-medium text-gray-600">创建新标签:</div> <div class="mb-2 text-xs font-medium text-gray-600 dark:text-gray-400">
创建新标签:
</div>
<div class="flex gap-2"> <div class="flex gap-2">
<input <input
v-model="newTag" v-model="newTag"
class="form-input flex-1" class="form-input flex-1 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
placeholder="输入新标签名称" placeholder="输入新标签名称"
type="text" type="text"
@keypress.enter.prevent="addTag" @keypress.enter.prevent="addTag"
@@ -179,65 +199,79 @@
</div> </div>
</div> </div>
<p class="text-xs text-gray-500">用于标记不同团队或用途方便筛选管理</p> <p class="text-xs text-gray-500 dark:text-gray-400">
用于标记不同团队或用途方便筛选管理
</p>
</div> </div>
</div> </div>
<!-- 速率限制设置 --> <!-- 速率限制设置 -->
<div class="rounded-lg border border-blue-200 bg-blue-50 p-3"> <div
class="rounded-lg border border-blue-200 bg-blue-50 p-3 dark:border-blue-700 dark:bg-blue-900/20"
>
<div class="mb-2 flex items-center gap-2"> <div class="mb-2 flex items-center gap-2">
<div <div
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded bg-blue-500" class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded bg-blue-500"
> >
<i class="fas fa-tachometer-alt text-xs text-white" /> <i class="fas fa-tachometer-alt text-xs text-white" />
</div> </div>
<h4 class="text-sm font-semibold text-gray-800">速率限制设置 (可选)</h4> <h4 class="text-sm font-semibold text-gray-800 dark:text-gray-200">
速率限制设置 (可选)
</h4>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<div class="grid grid-cols-1 gap-2 lg:grid-cols-3"> <div class="grid grid-cols-1 gap-2 lg:grid-cols-3">
<div> <div>
<label class="mb-1 block text-xs font-medium text-gray-700" <label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300"
>时间窗口 (分钟)</label >时间窗口 (分钟)</label
> >
<input <input
v-model="form.rateLimitWindow" v-model="form.rateLimitWindow"
class="form-input w-full text-sm" class="form-input w-full text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
min="1" min="1"
placeholder="无限制" placeholder="无限制"
type="number" type="number"
/> />
<p class="ml-2 mt-0.5 text-xs text-gray-500">时间段单位</p> <p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">时间段单位</p>
</div> </div>
<div> <div>
<label class="mb-1 block text-xs font-medium text-gray-700">请求次数限制</label> <label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300"
>请求次数限制</label
>
<input <input
v-model="form.rateLimitRequests" v-model="form.rateLimitRequests"
class="form-input w-full text-sm" class="form-input w-full text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
min="1" min="1"
placeholder="无限制" placeholder="无限制"
type="number" type="number"
/> />
<p class="ml-2 mt-0.5 text-xs text-gray-500">窗口内最大请求</p> <p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">窗口内最大请求</p>
</div> </div>
<div> <div>
<label class="mb-1 block text-xs font-medium text-gray-700">Token 限制</label> <label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300"
>Token 限制</label
>
<input <input
v-model="form.tokenLimit" v-model="form.tokenLimit"
class="form-input w-full text-sm" class="form-input w-full text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
placeholder="无限制" placeholder="无限制"
type="number" type="number"
/> />
<p class="ml-2 mt-0.5 text-xs text-gray-500">窗口内最大Token</p> <p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">
窗口内最大Token
</p>
</div> </div>
</div> </div>
<!-- 示例说明 --> <!-- 示例说明 -->
<div class="rounded-lg bg-blue-100 p-2"> <div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30">
<h5 class="mb-1 text-xs font-semibold text-blue-800">💡 使用示例</h5> <h5 class="mb-1 text-xs font-semibold text-blue-800 dark:text-blue-400">
<div class="space-y-0.5 text-xs text-blue-700"> 💡 使用示例
</h5>
<div class="space-y-0.5 text-xs text-blue-700 dark:text-blue-300">
<div> <div>
<strong>示例1:</strong> 时间窗口=60请求次数=1000 每60分钟最多1000次请求 <strong>示例1:</strong> 时间窗口=60请求次数=1000 每60分钟最多1000次请求
</div> </div>
@@ -254,34 +288,34 @@
</div> </div>
<div> <div>
<label class="mb-2 block text-sm font-semibold text-gray-700" <label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>每日费用限制 (美元)</label >每日费用限制 (美元)</label
> >
<div class="space-y-2"> <div class="space-y-2">
<div class="flex gap-2"> <div class="flex gap-2">
<button <button
class="rounded bg-gray-100 px-2 py-1 text-xs font-medium hover:bg-gray-200" class="rounded bg-gray-100 px-2 py-1 text-xs font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button" type="button"
@click="form.dailyCostLimit = '50'" @click="form.dailyCostLimit = '50'"
> >
$50 $50
</button> </button>
<button <button
class="rounded bg-gray-100 px-2 py-1 text-xs font-medium hover:bg-gray-200" class="rounded bg-gray-100 px-2 py-1 text-xs font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button" type="button"
@click="form.dailyCostLimit = '100'" @click="form.dailyCostLimit = '100'"
> >
$100 $100
</button> </button>
<button <button
class="rounded bg-gray-100 px-2 py-1 text-xs font-medium hover:bg-gray-200" class="rounded bg-gray-100 px-2 py-1 text-xs font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button" type="button"
@click="form.dailyCostLimit = '200'" @click="form.dailyCostLimit = '200'"
> >
$200 $200
</button> </button>
<button <button
class="rounded bg-gray-100 px-2 py-1 text-xs font-medium hover:bg-gray-200" class="rounded bg-gray-100 px-2 py-1 text-xs font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button" type="button"
@click="form.dailyCostLimit = ''" @click="form.dailyCostLimit = ''"
> >
@@ -290,47 +324,53 @@
</div> </div>
<input <input
v-model="form.dailyCostLimit" v-model="form.dailyCostLimit"
class="form-input w-full" class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
min="0" min="0"
placeholder="0 表示无限制" placeholder="0 表示无限制"
step="0.01" step="0.01"
type="number" type="number"
/> />
<p class="text-xs text-gray-500"> <p class="text-xs text-gray-500 dark:text-gray-400">
设置此 API Key 每日的费用限制超过限制将拒绝请求0 或留空表示无限制 设置此 API Key 每日的费用限制超过限制将拒绝请求0 或留空表示无限制
</p> </p>
</div> </div>
</div> </div>
<div> <div>
<label class="mb-2 block text-sm font-semibold text-gray-700">并发限制 (可选)</label> <label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>并发限制 (可选)</label
>
<input <input
v-model="form.concurrencyLimit" v-model="form.concurrencyLimit"
class="form-input w-full" class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
min="0" min="0"
placeholder="0 表示无限制" placeholder="0 表示无限制"
type="number" type="number"
/> />
<p class="mt-2 text-xs text-gray-500"> <p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
设置此 API Key 可同时处理的最大请求数0 或留空表示无限制 设置此 API Key 可同时处理的最大请求数0 或留空表示无限制
</p> </p>
</div> </div>
<div> <div>
<label class="mb-2 block text-sm font-semibold text-gray-700">备注 (可选)</label> <label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>备注 (可选)</label
>
<textarea <textarea
v-model="form.description" v-model="form.description"
class="form-input w-full resize-none text-sm" class="form-input w-full resize-none text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
placeholder="描述此 API Key 的用途..." placeholder="描述此 API Key 的用途..."
rows="2" rows="2"
/> />
</div> </div>
<div> <div>
<label class="mb-2 block text-sm font-semibold text-gray-700">有效期限</label> <label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>有效期限</label
>
<select <select
v-model="form.expireDuration" v-model="form.expireDuration"
class="form-input w-full" class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
@change="updateExpireAt" @change="updateExpireAt"
> >
<option value="">永不过期</option> <option value="">永不过期</option>
@@ -345,45 +385,71 @@
<div v-if="form.expireDuration === 'custom'" class="mt-3"> <div v-if="form.expireDuration === 'custom'" class="mt-3">
<input <input
v-model="form.customExpireDate" v-model="form.customExpireDate"
class="form-input w-full" class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
:min="minDateTime" :min="minDateTime"
type="datetime-local" type="datetime-local"
@change="updateCustomExpireAt" @change="updateCustomExpireAt"
/> />
</div> </div>
<p v-if="form.expiresAt" class="mt-2 text-xs text-gray-500"> <p v-if="form.expiresAt" class="mt-2 text-xs text-gray-500 dark:text-gray-400">
将于 {{ formatExpireDate(form.expiresAt) }} 过期 将于 {{ formatExpireDate(form.expiresAt) }} 过期
</p> </p>
</div> </div>
<div> <div>
<label class="mb-2 block text-sm font-semibold text-gray-700">服务权限</label> <label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>服务权限</label
>
<div class="flex gap-4"> <div class="flex gap-4">
<label class="flex cursor-pointer items-center"> <label class="flex cursor-pointer items-center">
<input v-model="form.permissions" class="mr-2" type="radio" value="all" /> <input
<span class="text-sm text-gray-700">全部服务</span> v-model="form.permissions"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="radio"
value="all"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">全部服务</span>
</label> </label>
<label class="flex cursor-pointer items-center"> <label class="flex cursor-pointer items-center">
<input v-model="form.permissions" class="mr-2" type="radio" value="claude" /> <input
<span class="text-sm text-gray-700"> Claude</span> v-model="form.permissions"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="radio"
value="claude"
/>
<span class="text-sm text-gray-700 dark:text-gray-300"> Claude</span>
</label> </label>
<label class="flex cursor-pointer items-center"> <label class="flex cursor-pointer items-center">
<input v-model="form.permissions" class="mr-2" type="radio" value="gemini" /> <input
<span class="text-sm text-gray-700"> Gemini</span> v-model="form.permissions"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="radio"
value="gemini"
/>
<span class="text-sm text-gray-700 dark:text-gray-300"> Gemini</span>
</label> </label>
<label class="flex cursor-pointer items-center"> <label class="flex cursor-pointer items-center">
<input v-model="form.permissions" class="mr-2" type="radio" value="openai" /> <input
<span class="text-sm text-gray-700"> OpenAI</span> v-model="form.permissions"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="radio"
value="openai"
/>
<span class="text-sm text-gray-700 dark:text-gray-300"> OpenAI</span>
</label> </label>
</div> </div>
<p class="mt-2 text-xs text-gray-500">控制此 API Key 可以访问哪些服务</p> <p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
控制此 API Key 可以访问哪些服务
</p>
</div> </div>
<div> <div>
<div class="mb-2 flex items-center justify-between"> <div class="mb-2 flex items-center justify-between">
<label class="text-sm font-semibold text-gray-700">专属账号绑定 (可选)</label> <label class="text-sm font-semibold text-gray-700 dark:text-gray-300"
>专属账号绑定 (可选)</label
>
<button <button
class="flex items-center gap-1 text-sm text-blue-600 transition-colors hover:text-blue-800 disabled:cursor-not-allowed disabled:opacity-50" class="flex items-center gap-1 text-sm text-blue-600 transition-colors hover:text-blue-800 disabled:cursor-not-allowed disabled:opacity-50 dark:text-blue-400 dark:hover:text-blue-300"
:disabled="accountsLoading" :disabled="accountsLoading"
title="刷新账号列表" title="刷新账号列表"
type="button" type="button"
@@ -401,7 +467,9 @@
</div> </div>
<div class="grid grid-cols-1 gap-3"> <div class="grid grid-cols-1 gap-3">
<div> <div>
<label class="mb-1 block text-sm font-medium text-gray-600">Claude 专属账号</label> <label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400"
>Claude 专属账号</label
>
<AccountSelector <AccountSelector
v-model="form.claudeAccountId" v-model="form.claudeAccountId"
:accounts="localAccounts.claude" :accounts="localAccounts.claude"
@@ -413,7 +481,9 @@
/> />
</div> </div>
<div> <div>
<label class="mb-1 block text-sm font-medium text-gray-600">Gemini 专属账号</label> <label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400"
>Gemini 专属账号</label
>
<AccountSelector <AccountSelector
v-model="form.geminiAccountId" v-model="form.geminiAccountId"
:accounts="localAccounts.gemini" :accounts="localAccounts.gemini"
@@ -425,7 +495,9 @@
/> />
</div> </div>
<div> <div>
<label class="mb-1 block text-sm font-medium text-gray-600">OpenAI 专属账号</label> <label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400"
>OpenAI 专属账号</label
>
<AccountSelector <AccountSelector
v-model="form.openaiAccountId" v-model="form.openaiAccountId"
:accounts="localAccounts.openai" :accounts="localAccounts.openai"
@@ -437,7 +509,9 @@
/> />
</div> </div>
<div> <div>
<label class="mb-1 block text-sm font-medium text-gray-600">Bedrock 专属账号</label> <label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400"
>Bedrock 专属账号</label
>
<AccountSelector <AccountSelector
v-model="form.bedrockAccountId" v-model="form.bedrockAccountId"
:accounts="localAccounts.bedrock" :accounts="localAccounts.bedrock"
@@ -449,7 +523,7 @@
/> />
</div> </div>
</div> </div>
<p class="mt-2 text-xs text-gray-500"> <p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
选择专属账号后此API Key将只使用该账号不选择则使用共享账号池 选择专属账号后此API Key将只使用该账号不选择则使用共享账号池
</p> </p>
</div> </div>
@@ -463,7 +537,7 @@
type="checkbox" type="checkbox"
/> />
<label <label
class="ml-2 cursor-pointer text-sm font-semibold text-gray-700" class="ml-2 cursor-pointer text-sm font-semibold text-gray-700 dark:text-gray-300"
for="enableModelRestriction" for="enableModelRestriction"
> >
启用模型限制 启用模型限制
@@ -549,7 +623,7 @@
type="checkbox" type="checkbox"
/> />
<label <label
class="ml-2 cursor-pointer text-sm font-semibold text-gray-700" class="ml-2 cursor-pointer text-sm font-semibold text-gray-700 dark:text-gray-300"
for="enableClientRestriction" for="enableClientRestriction"
> >
启用客户端限制 启用客户端限制
@@ -558,10 +632,12 @@
<div <div
v-if="form.enableClientRestriction" v-if="form.enableClientRestriction"
class="rounded-lg border border-green-200 bg-green-50 p-3" class="rounded-lg border border-green-200 bg-green-50 p-3 dark:border-green-700 dark:bg-green-900/20"
> >
<div> <div>
<label class="mb-2 block text-xs font-medium text-gray-700">允许的客户端</label> <label class="mb-2 block text-xs font-medium text-gray-700 dark:text-gray-300"
>允许的客户端</label
>
<div class="space-y-1"> <div class="space-y-1">
<div v-for="client in supportedClients" :key="client.id" class="flex items-start"> <div v-for="client in supportedClients" :key="client.id" class="flex items-start">
<input <input
@@ -572,8 +648,12 @@
:value="client.id" :value="client.id"
/> />
<label class="ml-2 flex-1 cursor-pointer" :for="`client_${client.id}`"> <label class="ml-2 flex-1 cursor-pointer" :for="`client_${client.id}`">
<span class="text-sm font-medium text-gray-700">{{ client.name }}</span> <span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{
<span class="block text-xs text-gray-500">{{ client.description }}</span> client.name
}}</span>
<span class="block text-xs text-gray-500 dark:text-gray-400">{{
client.description
}}</span>
</label> </label>
</div> </div>
</div> </div>
@@ -583,7 +663,7 @@
<div class="flex gap-3 pt-2"> <div class="flex gap-3 pt-2">
<button <button
class="flex-1 rounded-lg bg-gray-100 px-4 py-2.5 text-sm font-semibold text-gray-700 transition-colors hover:bg-gray-200" class="flex-1 rounded-lg bg-gray-100 px-4 py-2.5 text-sm font-semibold text-gray-700 transition-colors hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button" type="button"
@click="$emit('close')" @click="$emit('close')"
> >

View File

@@ -11,10 +11,12 @@
> >
<i class="fas fa-edit text-sm text-white sm:text-base" /> <i class="fas fa-edit text-sm text-white sm:text-base" />
</div> </div>
<h3 class="text-lg font-bold text-gray-900 sm:text-xl">编辑 API Key</h3> <h3 class="text-lg font-bold text-gray-900 dark:text-gray-100 sm:text-xl">
编辑 API Key
</h3>
</div> </div>
<button <button
class="p-1 text-gray-400 transition-colors hover:text-gray-600" class="p-1 text-gray-400 transition-colors hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
@click="$emit('close')" @click="$emit('close')"
> >
<i class="fas fa-times text-lg sm:text-xl" /> <i class="fas fa-times text-lg sm:text-xl" />
@@ -26,36 +28,40 @@
@submit.prevent="updateApiKey" @submit.prevent="updateApiKey"
> >
<div> <div>
<label class="mb-1.5 block text-xs font-semibold text-gray-700 sm:mb-3 sm:text-sm" <label
class="mb-1.5 block text-xs font-semibold text-gray-700 dark:text-gray-300 sm:mb-3 sm:text-sm"
>名称</label >名称</label
> >
<input <input
class="form-input w-full cursor-not-allowed bg-gray-100 text-sm" class="form-input w-full cursor-not-allowed bg-gray-100 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400"
disabled disabled
type="text" type="text"
:value="form.name" :value="form.name"
/> />
<p class="mt-1 text-xs text-gray-500 sm:mt-2">名称不可修改</p> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400 sm:mt-2">名称不可修改</p>
</div> </div>
<!-- 标签 --> <!-- 标签 -->
<div> <div>
<label class="mb-1.5 block text-xs font-semibold text-gray-700 sm:mb-3 sm:text-sm" <label
class="mb-1.5 block text-xs font-semibold text-gray-700 dark:text-gray-300 sm:mb-3 sm:text-sm"
>标签</label >标签</label
> >
<div class="space-y-4"> <div class="space-y-4">
<!-- 已选择的标签 --> <!-- 已选择的标签 -->
<div v-if="form.tags.length > 0"> <div v-if="form.tags.length > 0">
<div class="mb-2 text-xs font-medium text-gray-600">已选择的标签:</div> <div class="mb-2 text-xs font-medium text-gray-600 dark:text-gray-400">
已选择的标签:
</div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<span <span
v-for="(tag, index) in form.tags" v-for="(tag, index) in form.tags"
:key="'selected-' + index" :key="'selected-' + index"
class="inline-flex items-center gap-1 rounded-full bg-blue-100 px-3 py-1 text-sm text-blue-800" class="inline-flex items-center gap-1 rounded-full bg-blue-100 px-3 py-1 text-sm text-blue-800 dark:bg-blue-900/30 dark:text-blue-400"
> >
{{ tag }} {{ tag }}
<button <button
class="ml-1 hover:text-blue-900" class="ml-1 hover:text-blue-900 dark:hover:text-blue-300"
type="button" type="button"
@click="removeTag(index)" @click="removeTag(index)"
> >
@@ -67,16 +73,18 @@
<!-- 可选择的已有标签 --> <!-- 可选择的已有标签 -->
<div v-if="unselectedTags.length > 0"> <div v-if="unselectedTags.length > 0">
<div class="mb-2 text-xs font-medium text-gray-600">点击选择已有标签:</div> <div class="mb-2 text-xs font-medium text-gray-600 dark:text-gray-400">
点击选择已有标签:
</div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<button <button
v-for="tag in unselectedTags" v-for="tag in unselectedTags"
:key="'available-' + tag" :key="'available-' + tag"
class="inline-flex items-center gap-1 rounded-full bg-gray-100 px-3 py-1 text-sm text-gray-700 transition-colors hover:bg-blue-100 hover:text-blue-700" class="inline-flex items-center gap-1 rounded-full bg-gray-100 px-3 py-1 text-sm text-gray-700 transition-colors hover:bg-blue-100 hover:text-blue-700 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-blue-900/30 dark:hover:text-blue-400"
type="button" type="button"
@click="selectTag(tag)" @click="selectTag(tag)"
> >
<i class="fas fa-tag text-xs text-gray-500" /> <i class="fas fa-tag text-xs text-gray-500 dark:text-gray-400" />
{{ tag }} {{ tag }}
</button> </button>
</div> </div>
@@ -84,11 +92,13 @@
<!-- 创建新标签 --> <!-- 创建新标签 -->
<div> <div>
<div class="mb-2 text-xs font-medium text-gray-600">创建新标签:</div> <div class="mb-2 text-xs font-medium text-gray-600 dark:text-gray-400">
创建新标签:
</div>
<div class="flex gap-2"> <div class="flex gap-2">
<input <input
v-model="newTag" v-model="newTag"
class="form-input flex-1" class="form-input flex-1 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
placeholder="输入新标签名称" placeholder="输入新标签名称"
type="text" type="text"
@keypress.enter.prevent="addTag" @keypress.enter.prevent="addTag"
@@ -103,65 +113,79 @@
</div> </div>
</div> </div>
<p class="text-xs text-gray-500">用于标记不同团队或用途方便筛选管理</p> <p class="text-xs text-gray-500 dark:text-gray-400">
用于标记不同团队或用途方便筛选管理
</p>
</div> </div>
</div> </div>
<!-- 速率限制设置 --> <!-- 速率限制设置 -->
<div class="rounded-lg border border-blue-200 bg-blue-50 p-3"> <div
class="rounded-lg border border-blue-200 bg-blue-50 p-3 dark:border-blue-700 dark:bg-blue-900/20"
>
<div class="mb-2 flex items-center gap-2"> <div class="mb-2 flex items-center gap-2">
<div <div
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded bg-blue-500" class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded bg-blue-500"
> >
<i class="fas fa-tachometer-alt text-xs text-white" /> <i class="fas fa-tachometer-alt text-xs text-white" />
</div> </div>
<h4 class="text-sm font-semibold text-gray-800">速率限制设置 (可选)</h4> <h4 class="text-sm font-semibold text-gray-800 dark:text-gray-200">
速率限制设置 (可选)
</h4>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<div class="grid grid-cols-1 gap-2 lg:grid-cols-3"> <div class="grid grid-cols-1 gap-2 lg:grid-cols-3">
<div> <div>
<label class="mb-1 block text-xs font-medium text-gray-700" <label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300"
>时间窗口 (分钟)</label >时间窗口 (分钟)</label
> >
<input <input
v-model="form.rateLimitWindow" v-model="form.rateLimitWindow"
class="form-input w-full text-sm" class="form-input w-full text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
min="1" min="1"
placeholder="无限制" placeholder="无限制"
type="number" type="number"
/> />
<p class="ml-2 mt-0.5 text-xs text-gray-500">时间段单位</p> <p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">时间段单位</p>
</div> </div>
<div> <div>
<label class="mb-1 block text-xs font-medium text-gray-700">请求次数限制</label> <label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300"
>请求次数限制</label
>
<input <input
v-model="form.rateLimitRequests" v-model="form.rateLimitRequests"
class="form-input w-full text-sm" class="form-input w-full text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
min="1" min="1"
placeholder="无限制" placeholder="无限制"
type="number" type="number"
/> />
<p class="ml-2 mt-0.5 text-xs text-gray-500">窗口内最大请求</p> <p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">窗口内最大请求</p>
</div> </div>
<div> <div>
<label class="mb-1 block text-xs font-medium text-gray-700">Token 限制</label> <label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300"
>Token 限制</label
>
<input <input
v-model="form.tokenLimit" v-model="form.tokenLimit"
class="form-input w-full text-sm" class="form-input w-full text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
placeholder="无限制" placeholder="无限制"
type="number" type="number"
/> />
<p class="ml-2 mt-0.5 text-xs text-gray-500">窗口内最大Token</p> <p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">
窗口内最大Token
</p>
</div> </div>
</div> </div>
<!-- 示例说明 --> <!-- 示例说明 -->
<div class="rounded-lg bg-blue-100 p-2"> <div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30">
<h5 class="mb-1 text-xs font-semibold text-blue-800">💡 使用示例</h5> <h5 class="mb-1 text-xs font-semibold text-blue-800 dark:text-blue-400">
<div class="space-y-0.5 text-xs text-blue-700"> 💡 使用示例
</h5>
<div class="space-y-0.5 text-xs text-blue-700 dark:text-blue-300">
<div> <div>
<strong>示例1:</strong> 时间窗口=60请求次数=1000 每60分钟最多1000次请求 <strong>示例1:</strong> 时间窗口=60请求次数=1000 每60分钟最多1000次请求
</div> </div>
@@ -178,34 +202,34 @@
</div> </div>
<div> <div>
<label class="mb-3 block text-sm font-semibold text-gray-700" <label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>每日费用限制 (美元)</label >每日费用限制 (美元)</label
> >
<div class="space-y-3"> <div class="space-y-3">
<div class="flex gap-2"> <div class="flex gap-2">
<button <button
class="rounded-lg bg-gray-100 px-3 py-1 text-sm font-medium hover:bg-gray-200" class="rounded-lg bg-gray-100 px-3 py-1 text-sm font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button" type="button"
@click="form.dailyCostLimit = '50'" @click="form.dailyCostLimit = '50'"
> >
$50 $50
</button> </button>
<button <button
class="rounded-lg bg-gray-100 px-3 py-1 text-sm font-medium hover:bg-gray-200" class="rounded-lg bg-gray-100 px-3 py-1 text-sm font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button" type="button"
@click="form.dailyCostLimit = '100'" @click="form.dailyCostLimit = '100'"
> >
$100 $100
</button> </button>
<button <button
class="rounded-lg bg-gray-100 px-3 py-1 text-sm font-medium hover:bg-gray-200" class="rounded-lg bg-gray-100 px-3 py-1 text-sm font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button" type="button"
@click="form.dailyCostLimit = '200'" @click="form.dailyCostLimit = '200'"
> >
$200 $200
</button> </button>
<button <button
class="rounded-lg bg-gray-100 px-3 py-1 text-sm font-medium hover:bg-gray-200" class="rounded-lg bg-gray-100 px-3 py-1 text-sm font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button" type="button"
@click="form.dailyCostLimit = ''" @click="form.dailyCostLimit = ''"
> >
@@ -214,28 +238,32 @@
</div> </div>
<input <input
v-model="form.dailyCostLimit" v-model="form.dailyCostLimit"
class="form-input w-full" class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
min="0" min="0"
placeholder="0 表示无限制" placeholder="0 表示无限制"
step="0.01" step="0.01"
type="number" type="number"
/> />
<p class="text-xs text-gray-500"> <p class="text-xs text-gray-500 dark:text-gray-400">
设置此 API Key 每日的费用限制超过限制将拒绝请求0 或留空表示无限制 设置此 API Key 每日的费用限制超过限制将拒绝请求0 或留空表示无限制
</p> </p>
</div> </div>
</div> </div>
<div> <div>
<label class="mb-3 block text-sm font-semibold text-gray-700">并发限制</label> <label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>并发限制</label
>
<input <input
v-model="form.concurrencyLimit" v-model="form.concurrencyLimit"
class="form-input w-full" class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
min="0" min="0"
placeholder="0 表示无限制" placeholder="0 表示无限制"
type="number" type="number"
/> />
<p class="mt-2 text-xs text-gray-500">设置此 API Key 可同时处理的最大请求数</p> <p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
设置此 API Key 可同时处理的最大请求数
</p>
</div> </div>
<!-- 激活账号 --> <!-- 激活账号 -->
@@ -244,49 +272,75 @@
<input <input
id="editIsActive" id="editIsActive"
v-model="form.isActive" v-model="form.isActive"
class="h-4 w-4 rounded border-gray-300 bg-gray-100 text-blue-600 focus:ring-blue-500" class="h-4 w-4 rounded border-gray-300 bg-gray-100 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox" type="checkbox"
/> />
<label <label
class="ml-2 cursor-pointer text-sm font-semibold text-gray-700" class="ml-2 cursor-pointer text-sm font-semibold text-gray-700 dark:text-gray-300"
for="editIsActive" for="editIsActive"
> >
激活账号 激活账号
</label> </label>
</div> </div>
<p class="mb-4 text-xs text-gray-500"> <p class="mb-4 text-xs text-gray-500 dark:text-gray-400">
取消勾选将禁用此 API Key暂停所有请求客户端返回 401 错误 取消勾选将禁用此 API Key暂停所有请求客户端返回 401 错误
</p> </p>
</div> </div>
<div> <div>
<label class="mb-3 block text-sm font-semibold text-gray-700">服务权限</label> <label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>服务权限</label
>
<div class="flex gap-4"> <div class="flex gap-4">
<label class="flex cursor-pointer items-center"> <label class="flex cursor-pointer items-center">
<input v-model="form.permissions" class="mr-2" type="radio" value="all" /> <input
<span class="text-sm text-gray-700">全部服务</span> v-model="form.permissions"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="radio"
value="all"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">全部服务</span>
</label> </label>
<label class="flex cursor-pointer items-center"> <label class="flex cursor-pointer items-center">
<input v-model="form.permissions" class="mr-2" type="radio" value="claude" /> <input
<span class="text-sm text-gray-700"> Claude</span> v-model="form.permissions"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="radio"
value="claude"
/>
<span class="text-sm text-gray-700 dark:text-gray-300"> Claude</span>
</label> </label>
<label class="flex cursor-pointer items-center"> <label class="flex cursor-pointer items-center">
<input v-model="form.permissions" class="mr-2" type="radio" value="gemini" /> <input
<span class="text-sm text-gray-700"> Gemini</span> v-model="form.permissions"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="radio"
value="gemini"
/>
<span class="text-sm text-gray-700 dark:text-gray-300"> Gemini</span>
</label> </label>
<label class="flex cursor-pointer items-center"> <label class="flex cursor-pointer items-center">
<input v-model="form.permissions" class="mr-2" type="radio" value="openai" /> <input
<span class="text-sm text-gray-700"> OpenAI</span> v-model="form.permissions"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="radio"
value="openai"
/>
<span class="text-sm text-gray-700 dark:text-gray-300"> OpenAI</span>
</label> </label>
</div> </div>
<p class="mt-2 text-xs text-gray-500">控制此 API Key 可以访问哪些服务</p> <p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
控制此 API Key 可以访问哪些服务
</p>
</div> </div>
<div> <div>
<div class="mb-3 flex items-center justify-between"> <div class="mb-3 flex items-center justify-between">
<label class="text-sm font-semibold text-gray-700">专属账号绑定</label> <label class="text-sm font-semibold text-gray-700 dark:text-gray-300"
>专属账号绑定</label
>
<button <button
class="flex items-center gap-1 text-sm text-blue-600 transition-colors hover:text-blue-800 disabled:cursor-not-allowed disabled:opacity-50" class="flex items-center gap-1 text-sm text-blue-600 transition-colors hover:text-blue-800 disabled:cursor-not-allowed disabled:opacity-50 dark:text-blue-400 dark:hover:text-blue-300"
:disabled="accountsLoading" :disabled="accountsLoading"
title="刷新账号列表" title="刷新账号列表"
type="button" type="button"
@@ -304,7 +358,9 @@
</div> </div>
<div class="grid grid-cols-1 gap-3"> <div class="grid grid-cols-1 gap-3">
<div> <div>
<label class="mb-1 block text-sm font-medium text-gray-600">Claude 专属账号</label> <label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400"
>Claude 专属账号</label
>
<AccountSelector <AccountSelector
v-model="form.claudeAccountId" v-model="form.claudeAccountId"
:accounts="localAccounts.claude" :accounts="localAccounts.claude"
@@ -316,7 +372,9 @@
/> />
</div> </div>
<div> <div>
<label class="mb-1 block text-sm font-medium text-gray-600">Gemini 专属账号</label> <label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400"
>Gemini 专属账号</label
>
<AccountSelector <AccountSelector
v-model="form.geminiAccountId" v-model="form.geminiAccountId"
:accounts="localAccounts.gemini" :accounts="localAccounts.gemini"
@@ -328,7 +386,9 @@
/> />
</div> </div>
<div> <div>
<label class="mb-1 block text-sm font-medium text-gray-600">OpenAI 专属账号</label> <label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400"
>OpenAI 专属账号</label
>
<AccountSelector <AccountSelector
v-model="form.openaiAccountId" v-model="form.openaiAccountId"
:accounts="localAccounts.openai" :accounts="localAccounts.openai"
@@ -340,7 +400,9 @@
/> />
</div> </div>
<div> <div>
<label class="mb-1 block text-sm font-medium text-gray-600">Bedrock 专属账号</label> <label class="mb-1 block text-sm font-medium text-gray-600 dark:text-gray-400"
>Bedrock 专属账号</label
>
<AccountSelector <AccountSelector
v-model="form.bedrockAccountId" v-model="form.bedrockAccountId"
:accounts="localAccounts.bedrock" :accounts="localAccounts.bedrock"
@@ -352,7 +414,9 @@
/> />
</div> </div>
</div> </div>
<p class="mt-2 text-xs text-gray-500">修改绑定账号将影响此API Key的请求路由</p> <p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
修改绑定账号将影响此API Key的请求路由
</p>
</div> </div>
<div> <div>
@@ -360,11 +424,11 @@
<input <input
id="editEnableModelRestriction" id="editEnableModelRestriction"
v-model="form.enableModelRestriction" v-model="form.enableModelRestriction"
class="h-4 w-4 rounded border-gray-300 bg-gray-100 text-blue-600 focus:ring-blue-500" class="h-4 w-4 rounded border-gray-300 bg-gray-100 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox" type="checkbox"
/> />
<label <label
class="ml-2 cursor-pointer text-sm font-semibold text-gray-700" class="ml-2 cursor-pointer text-sm font-semibold text-gray-700 dark:text-gray-300"
for="editEnableModelRestriction" for="editEnableModelRestriction"
> >
启用模型限制 启用模型限制
@@ -373,25 +437,30 @@
<div v-if="form.enableModelRestriction" class="space-y-3"> <div v-if="form.enableModelRestriction" class="space-y-3">
<div> <div>
<label class="mb-2 block text-sm font-medium text-gray-600">限制的模型列表</label> <label class="mb-2 block text-sm font-medium text-gray-600 dark:text-gray-400"
>限制的模型列表</label
>
<div <div
class="mb-3 flex min-h-[32px] flex-wrap gap-2 rounded-lg border border-gray-200 bg-gray-50 p-2" class="mb-3 flex min-h-[32px] flex-wrap gap-2 rounded-lg border border-gray-200 bg-gray-50 p-2 dark:border-gray-600 dark:bg-gray-700"
> >
<span <span
v-for="(model, index) in form.restrictedModels" v-for="(model, index) in form.restrictedModels"
:key="index" :key="index"
class="inline-flex items-center rounded-full bg-red-100 px-3 py-1 text-sm text-red-800" class="inline-flex items-center rounded-full bg-red-100 px-3 py-1 text-sm text-red-800 dark:bg-red-900/30 dark:text-red-400"
> >
{{ model }} {{ model }}
<button <button
class="ml-2 text-red-600 hover:text-red-800" class="ml-2 text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300"
type="button" type="button"
@click="removeRestrictedModel(index)" @click="removeRestrictedModel(index)"
> >
<i class="fas fa-times text-xs" /> <i class="fas fa-times text-xs" />
</button> </button>
</span> </span>
<span v-if="form.restrictedModels.length === 0" class="text-sm text-gray-400"> <span
v-if="form.restrictedModels.length === 0"
class="text-sm text-gray-400 dark:text-gray-500"
>
暂无限制的模型 暂无限制的模型
</span> </span>
</div> </div>
@@ -401,7 +470,7 @@
<button <button
v-for="model in availableQuickModels" v-for="model in availableQuickModels"
:key="model" :key="model"
class="flex-shrink-0 rounded-lg bg-gray-100 px-3 py-1 text-xs text-gray-700 transition-colors hover:bg-gray-200 sm:text-sm" class="flex-shrink-0 rounded-lg bg-gray-100 px-3 py-1 text-xs text-gray-700 transition-colors hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 sm:text-sm"
type="button" type="button"
@click="quickAddRestrictedModel(model)" @click="quickAddRestrictedModel(model)"
> >
@@ -409,7 +478,7 @@
</button> </button>
<span <span
v-if="availableQuickModels.length === 0" v-if="availableQuickModels.length === 0"
class="text-sm italic text-gray-400" class="text-sm italic text-gray-400 dark:text-gray-500"
> >
所有常用模型已在限制列表中 所有常用模型已在限制列表中
</span> </span>
@@ -419,7 +488,7 @@
<div class="flex gap-2"> <div class="flex gap-2">
<input <input
v-model="form.modelInput" v-model="form.modelInput"
class="form-input flex-1" class="form-input flex-1 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
placeholder="输入模型名称,按回车添加" placeholder="输入模型名称,按回车添加"
type="text" type="text"
@keydown.enter.prevent="addRestrictedModel" @keydown.enter.prevent="addRestrictedModel"
@@ -433,7 +502,7 @@
</button> </button>
</div> </div>
</div> </div>
<p class="mt-2 text-xs text-gray-500"> <p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
设置此API Key无法访问的模型例如claude-opus-4-20250514 设置此API Key无法访问的模型例如claude-opus-4-20250514
</p> </p>
</div> </div>
@@ -446,11 +515,11 @@
<input <input
id="editEnableClientRestriction" id="editEnableClientRestriction"
v-model="form.enableClientRestriction" v-model="form.enableClientRestriction"
class="h-4 w-4 rounded border-gray-300 bg-gray-100 text-blue-600 focus:ring-blue-500" class="h-4 w-4 rounded border-gray-300 bg-gray-100 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox" type="checkbox"
/> />
<label <label
class="ml-2 cursor-pointer text-sm font-semibold text-gray-700" class="ml-2 cursor-pointer text-sm font-semibold text-gray-700 dark:text-gray-300"
for="editEnableClientRestriction" for="editEnableClientRestriction"
> >
启用客户端限制 启用客户端限制
@@ -459,8 +528,12 @@
<div v-if="form.enableClientRestriction" class="space-y-3"> <div v-if="form.enableClientRestriction" class="space-y-3">
<div> <div>
<label class="mb-2 block text-sm font-medium text-gray-600">允许的客户端</label> <label class="mb-2 block text-sm font-medium text-gray-600 dark:text-gray-400"
<p class="mb-3 text-xs text-gray-500">勾选允许使用此API Key的客户端</p> >允许的客户端</label
>
<p class="mb-3 text-xs text-gray-500 dark:text-gray-400">
勾选允许使用此API Key的客户端
</p>
<div class="space-y-2"> <div class="space-y-2">
<div v-for="client in supportedClients" :key="client.id" class="flex items-start"> <div v-for="client in supportedClients" :key="client.id" class="flex items-start">
<input <input
@@ -471,8 +544,12 @@
:value="client.id" :value="client.id"
/> />
<label class="ml-2 flex-1 cursor-pointer" :for="`edit_client_${client.id}`"> <label class="ml-2 flex-1 cursor-pointer" :for="`edit_client_${client.id}`">
<span class="text-sm font-medium text-gray-700">{{ client.name }}</span> <span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{
<span class="block text-xs text-gray-500">{{ client.description }}</span> client.name
}}</span>
<span class="block text-xs text-gray-500 dark:text-gray-400">{{
client.description
}}</span>
</label> </label>
</div> </div>
</div> </div>
@@ -482,7 +559,7 @@
<div class="flex gap-3 pt-4"> <div class="flex gap-3 pt-4">
<button <button
class="flex-1 rounded-xl bg-gray-100 px-6 py-3 font-semibold text-gray-700 transition-colors hover:bg-gray-200" class="flex-1 rounded-xl bg-gray-100 px-6 py-3 font-semibold text-gray-700 transition-colors hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button" type="button"
@click="$emit('close')" @click="$emit('close')"
> >

View File

@@ -1,8 +1,14 @@
<template> <template>
<Teleport to="body"> <Teleport to="body">
<div v-if="show" class="modal fixed inset-0 z-50 flex items-center justify-center p-4"> <div v-if="show" class="modal fixed inset-0 z-50 flex items-center justify-center p-4">
<!-- 背景遮罩 -->
<div
class="fixed inset-0 bg-gray-900 bg-opacity-50 backdrop-blur-sm"
@click="$emit('close')"
/>
<!-- 模态框内容 --> <!-- 模态框内容 -->
<div class="modal-content mx-auto w-full max-w-lg p-8"> <div class="modal-content relative mx-auto w-full max-w-lg p-8">
<!-- 头部 --> <!-- 头部 -->
<div class="mb-6 flex items-center justify-between"> <div class="mb-6 flex items-center justify-between">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
@@ -12,14 +18,14 @@
<i class="fas fa-clock text-white" /> <i class="fas fa-clock text-white" />
</div> </div>
<div> <div>
<h3 class="text-xl font-bold text-gray-900">修改过期时间</h3> <h3 class="text-xl font-bold text-gray-900 dark:text-gray-100">修改过期时间</h3>
<p class="text-sm text-gray-600"> <p class="text-sm text-gray-600 dark:text-gray-400">
"{{ apiKey.name || 'API Key' }}" 设置新的过期时间 "{{ apiKey.name || 'API Key' }}" 设置新的过期时间
</p> </p>
</div> </div>
</div> </div>
<button <button
class="text-gray-400 transition-colors hover:text-gray-600" class="text-gray-400 transition-colors hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
@click="$emit('close')" @click="$emit('close')"
> >
<i class="fas fa-times text-xl" /> <i class="fas fa-times text-xl" />
@@ -29,12 +35,14 @@
<div class="space-y-6"> <div class="space-y-6">
<!-- 当前状态显示 --> <!-- 当前状态显示 -->
<div <div
class="rounded-lg border border-gray-200 bg-gradient-to-r from-gray-50 to-gray-100 p-4" class="rounded-lg border border-gray-200 bg-gradient-to-r from-gray-50 to-gray-100 p-4 dark:border-gray-600 dark:from-gray-700 dark:to-gray-800"
> >
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<p class="mb-1 text-xs font-medium text-gray-600">当前过期时间</p> <p class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-400">
<p class="text-sm font-semibold text-gray-800"> 当前过期时间
</p>
<p class="text-sm font-semibold text-gray-800 dark:text-gray-200">
<template v-if="apiKey.expiresAt"> <template v-if="apiKey.expiresAt">
{{ formatExpireDate(apiKey.expiresAt) }} {{ formatExpireDate(apiKey.expiresAt) }}
<span <span
@@ -51,7 +59,9 @@
</template> </template>
</p> </p>
</div> </div>
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-white shadow-sm"> <div
class="flex h-12 w-12 items-center justify-center rounded-lg bg-white shadow-sm dark:bg-gray-700"
>
<i <i
:class="[ :class="[
'fas fa-hourglass-half text-lg', 'fas fa-hourglass-half text-lg',
@@ -66,7 +76,9 @@
<!-- 快捷选项 --> <!-- 快捷选项 -->
<div> <div>
<label class="mb-3 block text-sm font-semibold text-gray-700">选择新的期限</label> <label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>选择新的期限</label
>
<div class="mb-3 grid grid-cols-3 gap-2"> <div class="mb-3 grid grid-cols-3 gap-2">
<button <button
v-for="option in quickOptions" v-for="option in quickOptions"
@@ -75,7 +87,7 @@
'rounded-lg px-3 py-2 text-sm font-medium transition-all', 'rounded-lg px-3 py-2 text-sm font-medium transition-all',
localForm.expireDuration === option.value localForm.expireDuration === option.value
? 'bg-blue-500 text-white shadow-md' ? 'bg-blue-500 text-white shadow-md'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200' : 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'
]" ]"
@click="selectQuickOption(option.value)" @click="selectQuickOption(option.value)"
> >
@@ -86,7 +98,7 @@
'rounded-lg px-3 py-2 text-sm font-medium transition-all', 'rounded-lg px-3 py-2 text-sm font-medium transition-all',
localForm.expireDuration === 'custom' localForm.expireDuration === 'custom'
? 'bg-blue-500 text-white shadow-md' ? 'bg-blue-500 text-white shadow-md'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200' : 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'
]" ]"
@click="selectQuickOption('custom')" @click="selectQuickOption('custom')"
> >
@@ -98,29 +110,33 @@
<!-- 自定义日期选择 --> <!-- 自定义日期选择 -->
<div v-if="localForm.expireDuration === 'custom'" class="animate-fadeIn"> <div v-if="localForm.expireDuration === 'custom'" class="animate-fadeIn">
<label class="mb-2 block text-sm font-semibold text-gray-700">选择日期和时间</label> <label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>选择日期和时间</label
>
<input <input
v-model="localForm.customExpireDate" v-model="localForm.customExpireDate"
class="form-input w-full" class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
:min="minDateTime" :min="minDateTime"
type="datetime-local" type="datetime-local"
@change="updateCustomExpiryPreview" @change="updateCustomExpiryPreview"
/> />
<p class="mt-2 text-xs text-gray-500">选择一个未来的日期和时间作为过期时间</p> <p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
选择一个未来的日期和时间作为过期时间
</p>
</div> </div>
<!-- 预览新的过期时间 --> <!-- 预览新的过期时间 -->
<div <div
v-if="localForm.expiresAt !== apiKey.expiresAt" v-if="localForm.expiresAt !== apiKey.expiresAt"
class="rounded-lg border border-blue-200 bg-gradient-to-r from-blue-50 to-indigo-50 p-4" class="rounded-lg border border-blue-200 bg-gradient-to-r from-blue-50 to-indigo-50 p-4 dark:border-blue-700 dark:from-blue-900/20 dark:to-indigo-900/20"
> >
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<p class="mb-1 text-xs font-medium text-blue-700"> <p class="mb-1 text-xs font-medium text-blue-700 dark:text-blue-400">
<i class="fas fa-arrow-right mr-1" /> <i class="fas fa-arrow-right mr-1" />
新的过期时间 新的过期时间
</p> </p>
<p class="text-sm font-semibold text-blue-900"> <p class="text-sm font-semibold text-blue-900 dark:text-blue-200">
<template v-if="localForm.expiresAt"> <template v-if="localForm.expiresAt">
{{ formatExpireDate(localForm.expiresAt) }} {{ formatExpireDate(localForm.expiresAt) }}
<span <span
@@ -137,7 +153,9 @@
</template> </template>
</p> </p>
</div> </div>
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-white shadow-sm"> <div
class="flex h-12 w-12 items-center justify-center rounded-lg bg-white shadow-sm dark:bg-gray-700"
>
<i class="fas fa-check text-lg text-green-500" /> <i class="fas fa-check text-lg text-green-500" />
</div> </div>
</div> </div>
@@ -146,7 +164,7 @@
<!-- 操作按钮 --> <!-- 操作按钮 -->
<div class="flex gap-3 pt-2"> <div class="flex gap-3 pt-2">
<button <button
class="flex-1 rounded-lg bg-gray-100 px-4 py-2.5 font-semibold text-gray-700 transition-colors hover:bg-gray-200" class="flex-1 rounded-lg bg-gray-100 px-4 py-2.5 font-semibold text-gray-700 transition-colors hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
@click="$emit('close')" @click="$emit('close')"
> >
取消 取消

View File

@@ -12,12 +12,12 @@
<i class="fas fa-check text-lg text-white" /> <i class="fas fa-check text-lg text-white" />
</div> </div>
<div> <div>
<h3 class="text-xl font-bold text-gray-900">API Key 创建成功</h3> <h3 class="text-xl font-bold text-gray-900 dark:text-gray-100">API Key 创建成功</h3>
<p class="text-sm text-gray-600">请妥善保存您的 API Key</p> <p class="text-sm text-gray-600 dark:text-gray-400">请妥善保存您的 API Key</p>
</div> </div>
</div> </div>
<button <button
class="text-gray-400 transition-colors hover:text-gray-600" class="text-gray-400 transition-colors hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
title="直接关闭(不推荐)" title="直接关闭(不推荐)"
@click="handleDirectClose" @click="handleDirectClose"
> >
@@ -26,16 +26,18 @@
</div> </div>
<!-- 警告提示 --> <!-- 警告提示 -->
<div class="mb-6 border-l-4 border-amber-400 bg-amber-50 p-4"> <div
class="mb-6 border-l-4 border-amber-400 bg-amber-50 p-4 dark:border-amber-500 dark:bg-amber-900/20"
>
<div class="flex items-start"> <div class="flex items-start">
<div <div
class="mt-0.5 flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-lg bg-amber-400" class="mt-0.5 flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-lg bg-amber-400 dark:bg-amber-500"
> >
<i class="fas fa-exclamation-triangle text-sm text-white" /> <i class="fas fa-exclamation-triangle text-sm text-white" />
</div> </div>
<div class="ml-3"> <div class="ml-3">
<h5 class="mb-1 font-semibold text-amber-900">重要提醒</h5> <h5 class="mb-1 font-semibold text-amber-900 dark:text-amber-400">重要提醒</h5>
<p class="text-sm text-amber-800"> <p class="text-sm text-amber-800 dark:text-amber-300">
这是您唯一能看到完整 API Key 的机会关闭此窗口后系统将不再显示完整的 API 这是您唯一能看到完整 API Key 的机会关闭此窗口后系统将不再显示完整的 API
Key请立即复制并妥善保存 Key请立即复制并妥善保存
</p> </p>
@@ -46,30 +48,42 @@
<!-- API Key 信息 --> <!-- API Key 信息 -->
<div class="mb-6 space-y-4"> <div class="mb-6 space-y-4">
<div> <div>
<label class="mb-2 block text-sm font-semibold text-gray-700">API Key 名称</label> <label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
<div class="rounded-lg border bg-gray-50 p-3"> >API Key 名称</label
<span class="font-medium text-gray-900">{{ apiKey.name }}</span> >
<div
class="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-600 dark:bg-gray-800"
>
<span class="font-medium text-gray-900 dark:text-gray-100">{{ apiKey.name }}</span>
</div> </div>
</div> </div>
<div v-if="apiKey.description"> <div v-if="apiKey.description">
<label class="mb-2 block text-sm font-semibold text-gray-700">备注</label> <label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
<div class="rounded-lg border bg-gray-50 p-3"> >备注</label
<span class="text-gray-700">{{ apiKey.description || '无描述' }}</span> >
<div
class="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-600 dark:bg-gray-800"
>
<span class="text-gray-700 dark:text-gray-300">{{
apiKey.description || '无描述'
}}</span>
</div> </div>
</div> </div>
<div> <div>
<label class="mb-2 block text-sm font-semibold text-gray-700">API Key</label> <label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>API Key</label
>
<div class="relative"> <div class="relative">
<div <div
class="flex min-h-[60px] items-center break-all rounded-lg border bg-gray-900 p-4 pr-14 font-mono text-sm text-white" class="flex min-h-[60px] items-center break-all rounded-lg border border-gray-700 bg-gray-900 p-4 pr-14 font-mono text-sm text-white dark:border-gray-600 dark:bg-gray-900"
> >
{{ getDisplayedApiKey() }} {{ getDisplayedApiKey() }}
</div> </div>
<div class="absolute right-3 top-3"> <div class="absolute right-3 top-3">
<button <button
class="btn-icon-sm bg-gray-700 hover:bg-gray-800" class="btn-icon-sm bg-gray-700 hover:bg-gray-800 dark:bg-gray-700 dark:hover:bg-gray-600"
:title="showFullKey ? '隐藏API Key' : '显示完整API Key'" :title="showFullKey ? '隐藏API Key' : '显示完整API Key'"
type="button" type="button"
@click="toggleKeyVisibility" @click="toggleKeyVisibility"
@@ -78,7 +92,7 @@
</button> </button>
</div> </div>
</div> </div>
<p class="mt-2 text-xs text-gray-500"> <p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
点击眼睛图标切换显示模式使用下方按钮复制完整 API Key 点击眼睛图标切换显示模式使用下方按钮复制完整 API Key
</p> </p>
</div> </div>
@@ -94,7 +108,7 @@
复制 API Key 复制 API Key
</button> </button>
<button <button
class="rounded-xl border border-gray-300 bg-gray-200 px-6 py-3 font-semibold text-gray-800 transition-colors hover:bg-gray-300" class="rounded-xl border border-gray-300 bg-gray-200 px-6 py-3 font-semibold text-gray-800 transition-colors hover:bg-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
@click="handleClose" @click="handleClose"
> >
我已保存 我已保存

View File

@@ -16,7 +16,7 @@
> >
<i class="fas fa-chart-line text-sm text-white sm:text-base" /> <i class="fas fa-chart-line text-sm text-white sm:text-base" />
</div> </div>
<h3 class="text-lg font-bold text-gray-900 sm:text-xl"> <h3 class="text-lg font-bold text-gray-900 dark:text-gray-100 sm:text-xl">
使用统计详情 - {{ apiKey.name }} 使用统计详情 - {{ apiKey.name }}
</h3> </h3>
</div> </div>
@@ -31,64 +31,68 @@
<div class="mb-6 grid grid-cols-1 gap-4 md:grid-cols-2"> <div class="mb-6 grid grid-cols-1 gap-4 md:grid-cols-2">
<!-- 请求统计卡片 --> <!-- 请求统计卡片 -->
<div <div
class="rounded-lg border border-blue-200 bg-gradient-to-br from-blue-50 to-blue-100 p-4" class="rounded-lg border border-blue-200 bg-gradient-to-br from-blue-50 to-blue-100 p-4 dark:border-blue-700 dark:from-blue-900/20 dark:to-blue-800/20"
> >
<div class="mb-3 flex items-center justify-between"> <div class="mb-3 flex items-center justify-between">
<span class="text-sm font-medium text-gray-700">总请求数</span> <span class="text-sm font-medium text-gray-700 dark:text-gray-300">总请求数</span>
<i class="fas fa-paper-plane text-blue-500" /> <i class="fas fa-paper-plane text-blue-500" />
</div> </div>
<div class="text-2xl font-bold text-gray-900"> <div class="text-2xl font-bold text-gray-900 dark:text-gray-100">
{{ formatNumber(totalRequests) }} {{ formatNumber(totalRequests) }}
</div> </div>
<div class="mt-1 text-xs text-gray-600"> <div class="mt-1 text-xs text-gray-600 dark:text-gray-400">
今日: {{ formatNumber(dailyRequests) }} 今日: {{ formatNumber(dailyRequests) }}
</div> </div>
</div> </div>
<!-- Token统计卡片 --> <!-- Token统计卡片 -->
<div <div
class="rounded-lg border border-green-200 bg-gradient-to-br from-green-50 to-green-100 p-4" class="rounded-lg border border-green-200 bg-gradient-to-br from-green-50 to-green-100 p-4 dark:border-green-700 dark:from-green-900/20 dark:to-green-800/20"
> >
<div class="mb-3 flex items-center justify-between"> <div class="mb-3 flex items-center justify-between">
<span class="text-sm font-medium text-gray-700">总Token数</span> <span class="text-sm font-medium text-gray-700 dark:text-gray-300">总Token数</span>
<i class="fas fa-coins text-green-500" /> <i class="fas fa-coins text-green-500" />
</div> </div>
<div class="text-2xl font-bold text-gray-900"> <div class="text-2xl font-bold text-gray-900 dark:text-gray-100">
{{ formatTokenCount(totalTokens) }} {{ formatTokenCount(totalTokens) }}
</div> </div>
<div class="mt-1 text-xs text-gray-600"> <div class="mt-1 text-xs text-gray-600 dark:text-gray-400">
今日: {{ formatTokenCount(dailyTokens) }} 今日: {{ formatTokenCount(dailyTokens) }}
</div> </div>
</div> </div>
<!-- 费用统计卡片 --> <!-- 费用统计卡片 -->
<div <div
class="rounded-lg border border-yellow-200 bg-gradient-to-br from-yellow-50 to-yellow-100 p-4" class="rounded-lg border border-yellow-200 bg-gradient-to-br from-yellow-50 to-yellow-100 p-4 dark:border-yellow-700 dark:from-yellow-900/20 dark:to-yellow-800/20"
> >
<div class="mb-3 flex items-center justify-between"> <div class="mb-3 flex items-center justify-between">
<span class="text-sm font-medium text-gray-700">总费用</span> <span class="text-sm font-medium text-gray-700 dark:text-gray-300">总费用</span>
<i class="fas fa-dollar-sign text-yellow-600" /> <i class="fas fa-dollar-sign text-yellow-600" />
</div> </div>
<div class="text-2xl font-bold text-gray-900">${{ totalCost.toFixed(4) }}</div> <div class="text-2xl font-bold text-gray-900 dark:text-gray-100">
<div class="mt-1 text-xs text-gray-600">今日: ${{ dailyCost.toFixed(4) }}</div> ${{ totalCost.toFixed(4) }}
</div>
<div class="mt-1 text-xs text-gray-600 dark:text-gray-400">
今日: ${{ dailyCost.toFixed(4) }}
</div>
</div> </div>
<!-- 平均统计卡片 --> <!-- 平均统计卡片 -->
<div <div
class="rounded-lg border border-purple-200 bg-gradient-to-br from-purple-50 to-purple-100 p-4" class="rounded-lg border border-purple-200 bg-gradient-to-br from-purple-50 to-purple-100 p-4 dark:border-purple-700 dark:from-purple-900/20 dark:to-purple-800/20"
> >
<div class="mb-3 flex items-center justify-between"> <div class="mb-3 flex items-center justify-between">
<span class="text-sm font-medium text-gray-700">平均速率</span> <span class="text-sm font-medium text-gray-700 dark:text-gray-300">平均速率</span>
<i class="fas fa-tachometer-alt text-purple-500" /> <i class="fas fa-tachometer-alt text-purple-500" />
</div> </div>
<div class="space-y-1 text-sm"> <div class="space-y-1 text-sm">
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-gray-600">RPM:</span> <span class="text-gray-600 dark:text-gray-400">RPM:</span>
<span class="font-semibold">{{ rpm }}</span> <span class="font-semibold text-gray-900 dark:text-gray-100">{{ rpm }}</span>
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-gray-600">TPM:</span> <span class="text-gray-600 dark:text-gray-400">TPM:</span>
<span class="font-semibold">{{ tpm }}</span> <span class="font-semibold text-gray-900 dark:text-gray-100">{{ tpm }}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -96,33 +100,35 @@
<!-- Token详细分布 --> <!-- Token详细分布 -->
<div class="mb-6"> <div class="mb-6">
<h4 class="mb-3 flex items-center text-sm font-semibold text-gray-700"> <h4
class="mb-3 flex items-center text-sm font-semibold text-gray-700 dark:text-gray-300"
>
<i class="fas fa-chart-pie mr-2 text-indigo-500" /> <i class="fas fa-chart-pie mr-2 text-indigo-500" />
Token 使用分布 Token 使用分布
</h4> </h4>
<div class="space-y-3 rounded-lg bg-gray-50 p-4"> <div class="space-y-3 rounded-lg bg-gray-50 p-4 dark:bg-gray-700/50">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center"> <div class="flex items-center">
<i class="fas fa-arrow-down mr-2 text-green-500" /> <i class="fas fa-arrow-down mr-2 text-green-500" />
<span class="text-sm text-gray-600">输入 Token</span> <span class="text-sm text-gray-600 dark:text-gray-400">输入 Token</span>
</div> </div>
<span class="text-sm font-semibold text-gray-900"> <span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ formatTokenCount(inputTokens) }} {{ formatTokenCount(inputTokens) }}
</span> </span>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center"> <div class="flex items-center">
<i class="fas fa-arrow-up mr-2 text-blue-500" /> <i class="fas fa-arrow-up mr-2 text-blue-500" />
<span class="text-sm text-gray-600">输出 Token</span> <span class="text-sm text-gray-600 dark:text-gray-400">输出 Token</span>
</div> </div>
<span class="text-sm font-semibold text-gray-900"> <span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ formatTokenCount(outputTokens) }} {{ formatTokenCount(outputTokens) }}
</span> </span>
</div> </div>
<div v-if="cacheCreateTokens > 0" class="flex items-center justify-between"> <div v-if="cacheCreateTokens > 0" class="flex items-center justify-between">
<div class="flex items-center"> <div class="flex items-center">
<i class="fas fa-save mr-2 text-purple-500" /> <i class="fas fa-save mr-2 text-purple-500" />
<span class="text-sm text-gray-600">缓存创建 Token</span> <span class="text-sm text-gray-600 dark:text-gray-400">缓存创建 Token</span>
</div> </div>
<span class="text-sm font-semibold text-purple-600"> <span class="text-sm font-semibold text-purple-600">
{{ formatTokenCount(cacheCreateTokens) }} {{ formatTokenCount(cacheCreateTokens) }}
@@ -131,7 +137,7 @@
<div v-if="cacheReadTokens > 0" class="flex items-center justify-between"> <div v-if="cacheReadTokens > 0" class="flex items-center justify-between">
<div class="flex items-center"> <div class="flex items-center">
<i class="fas fa-download mr-2 text-purple-500" /> <i class="fas fa-download mr-2 text-purple-500" />
<span class="text-sm text-gray-600">缓存读取 Token</span> <span class="text-sm text-gray-600 dark:text-gray-400">缓存读取 Token</span>
</div> </div>
<span class="text-sm font-semibold text-purple-600"> <span class="text-sm font-semibold text-purple-600">
{{ formatTokenCount(cacheReadTokens) }} {{ formatTokenCount(cacheReadTokens) }}
@@ -142,19 +148,21 @@
<!-- 限制信息 --> <!-- 限制信息 -->
<div v-if="hasLimits" class="mb-6"> <div v-if="hasLimits" class="mb-6">
<h4 class="mb-3 flex items-center text-sm font-semibold text-gray-700"> <h4
class="mb-3 flex items-center text-sm font-semibold text-gray-700 dark:text-gray-300"
>
<i class="fas fa-shield-alt mr-2 text-red-500" /> <i class="fas fa-shield-alt mr-2 text-red-500" />
限制设置 限制设置
</h4> </h4>
<div class="space-y-3 rounded-lg bg-gray-50 p-4"> <div class="space-y-3 rounded-lg bg-gray-50 p-4 dark:bg-gray-700/50">
<div v-if="apiKey.dailyCostLimit > 0" class="space-y-2"> <div v-if="apiKey.dailyCostLimit > 0" class="space-y-2">
<div class="flex items-center justify-between text-sm"> <div class="flex items-center justify-between text-sm">
<span class="text-gray-600">每日费用限制</span> <span class="text-gray-600 dark:text-gray-400">每日费用限制</span>
<span class="font-semibold text-gray-900"> <span class="font-semibold text-gray-900 dark:text-gray-100">
${{ apiKey.dailyCostLimit.toFixed(2) }} ${{ apiKey.dailyCostLimit.toFixed(2) }}
</span> </span>
</div> </div>
<div class="h-2 w-full rounded-full bg-gray-200"> <div class="h-2 w-full rounded-full bg-gray-200 dark:bg-gray-600">
<div <div
class="h-2 rounded-full transition-all duration-300" class="h-2 rounded-full transition-all duration-300"
:class=" :class="
@@ -167,7 +175,7 @@
:style="{ width: Math.min(dailyCostPercentage, 100) + '%' }" :style="{ width: Math.min(dailyCostPercentage, 100) + '%' }"
/> />
</div> </div>
<div class="text-right text-xs text-gray-500"> <div class="text-right text-xs text-gray-500 dark:text-gray-400">
已使用 {{ dailyCostPercentage.toFixed(1) }}% 已使用 {{ dailyCostPercentage.toFixed(1) }}%
</div> </div>
</div> </div>
@@ -176,14 +184,14 @@
v-if="apiKey.concurrencyLimit > 0" v-if="apiKey.concurrencyLimit > 0"
class="flex items-center justify-between text-sm" class="flex items-center justify-between text-sm"
> >
<span class="text-gray-600">并发限制</span> <span class="text-gray-600 dark:text-gray-400">并发限制</span>
<span class="font-semibold text-purple-600"> <span class="font-semibold text-purple-600">
{{ apiKey.currentConcurrency || 0 }} / {{ apiKey.concurrencyLimit }} {{ apiKey.currentConcurrency || 0 }} / {{ apiKey.concurrencyLimit }}
</span> </span>
</div> </div>
<div v-if="apiKey.rateLimitWindow > 0" class="space-y-2"> <div v-if="apiKey.rateLimitWindow > 0" class="space-y-2">
<h5 class="text-sm font-medium text-gray-700"> <h5 class="text-sm font-medium text-gray-700 dark:text-gray-300">
<i class="fas fa-clock mr-1 text-blue-500" /> <i class="fas fa-clock mr-1 text-blue-500" />
时间窗口限制 时间窗口限制
</h5> </h5>

View File

@@ -1,12 +1,12 @@
<template> <template>
<div class="api-input-wide-card glass-strong mb-8 rounded-3xl p-6 shadow-xl"> <div class="api-input-wide-card mb-8 rounded-3xl p-6 shadow-xl">
<!-- 标题区域 --> <!-- 标题区域 -->
<div class="wide-card-title mb-6 text-center"> <div class="wide-card-title mb-6 text-center">
<h2 class="mb-2 text-2xl font-bold"> <h2 class="mb-2 text-2xl font-bold text-gray-900 dark:text-white">
<i class="fas fa-chart-line mr-3" /> <i class="fas fa-chart-line mr-3" />
使用统计查询 使用统计查询
</h2> </h2>
<p class="text-base text-gray-600">查询您的 API Key 使用情况和统计数据</p> <p class="text-base text-gray-600 dark:text-gray-300">查询您的 API Key 使用情况和统计数据</p>
</div> </div>
<!-- 输入区域 --> <!-- 输入区域 -->
@@ -14,7 +14,7 @@
<div class="api-input-grid grid grid-cols-1 lg:grid-cols-4"> <div class="api-input-grid grid grid-cols-1 lg:grid-cols-4">
<!-- API Key 输入 --> <!-- API Key 输入 -->
<div class="lg:col-span-3"> <div class="lg:col-span-3">
<label class="mb-2 block text-sm font-medium text-gray-700"> <label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-200">
<i class="fas fa-key mr-2" /> <i class="fas fa-key mr-2" />
输入您的 API Key 输入您的 API Key
</label> </label>
@@ -30,7 +30,9 @@
<!-- 查询按钮 --> <!-- 查询按钮 -->
<div class="lg:col-span-1"> <div class="lg:col-span-1">
<label class="mb-2 hidden text-sm font-medium text-gray-700 lg:block"> &nbsp; </label> <label class="mb-2 hidden text-sm font-medium text-gray-700 dark:text-gray-200 lg:block">
&nbsp;
</label>
<button <button
class="btn btn-primary btn-query flex h-full w-full items-center justify-center gap-2" class="btn btn-primary btn-query flex h-full w-full items-center justify-center gap-2"
:disabled="loading || !apiKey.trim()" :disabled="loading || !apiKey.trim()"
@@ -62,11 +64,11 @@ const { queryStats } = apiStatsStore
</script> </script>
<style scoped> <style scoped>
/* 宽卡片样式 */ /* 宽卡片样式 - 使用CSS变量 */
.api-input-wide-card { .api-input-wide-card {
background: rgba(255, 255, 255, 0.95); background: var(--surface-color);
backdrop-filter: blur(25px); backdrop-filter: blur(25px);
border: 1px solid rgba(255, 255, 255, 0.3); border: 1px solid var(--border-color);
box-shadow: box-shadow:
0 25px 50px -12px rgba(0, 0, 0, 0.25), 0 25px 50px -12px rgba(0, 0, 0, 0.25),
0 0 0 1px rgba(255, 255, 255, 0.05), 0 0 0 1px rgba(255, 255, 255, 0.05),
@@ -74,6 +76,14 @@ const { queryStats } = apiStatsStore
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
} }
/* 暗夜模式宽卡片样式 */
:global(.dark) .api-input-wide-card {
box-shadow:
0 25px 50px -12px rgba(0, 0, 0, 0.6),
0 0 0 1px rgba(75, 85, 99, 0.2),
inset 0 1px 0 rgba(107, 114, 128, 0.15);
}
.api-input-wide-card:hover { .api-input-wide-card:hover {
box-shadow: box-shadow:
0 32px 64px -12px rgba(0, 0, 0, 0.35), 0 32px 64px -12px rgba(0, 0, 0, 0.35),
@@ -82,6 +92,13 @@ const { queryStats } = apiStatsStore
transform: translateY(-1px); transform: translateY(-1px);
} }
:global(.dark) .api-input-wide-card:hover {
box-shadow:
0 32px 64px -12px rgba(0, 0, 0, 0.7),
0 0 0 1px rgba(75, 85, 99, 0.25),
inset 0 1px 0 rgba(107, 114, 128, 0.3) !important;
}
/* 标题样式 */ /* 标题样式 */
.wide-card-title h2 { .wide-card-title h2 {
color: #1f2937; color: #1f2937;
@@ -89,11 +106,21 @@ const { queryStats } = apiStatsStore
font-weight: 700; font-weight: 700;
} }
:global(.dark) .wide-card-title h2 {
color: #f9fafb;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
.wide-card-title p { .wide-card-title p {
color: #4b5563; color: #4b5563;
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.05); text-shadow: 0 1px 1px rgba(0, 0, 0, 0.05);
} }
:global(.dark) .wide-card-title p {
color: #d1d5db;
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2);
}
.wide-card-title .fas.fa-chart-line { .wide-card-title .fas.fa-chart-line {
color: #3b82f6; color: #3b82f6;
text-shadow: 0 1px 2px rgba(59, 130, 246, 0.2); text-shadow: 0 1px 2px rgba(59, 130, 246, 0.2);
@@ -105,23 +132,32 @@ const { queryStats } = apiStatsStore
gap: 1rem; gap: 1rem;
} }
/* 输入框样式 */ /* 输入框样式 - 使用CSS变量 */
.wide-card-input { .wide-card-input {
background: rgba(255, 255, 255, 0.95); background: var(--input-bg);
border: 2px solid rgba(255, 255, 255, 0.4); border: 2px solid var(--input-border);
border-radius: 12px; border-radius: 12px;
padding: 14px 16px; padding: 14px 16px;
font-size: 16px; font-size: 16px;
transition: all 0.3s ease; transition: all 0.3s ease;
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
color: #1f2937; color: var(--text-primary);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
} }
:global(.dark) .wide-card-input {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.4);
color: #e5e7eb;
}
.wide-card-input::placeholder { .wide-card-input::placeholder {
color: #9ca3af; color: #9ca3af;
} }
:global(.dark) .wide-card-input::placeholder {
color: #64748b;
}
.wide-card-input:focus { .wide-card-input:focus {
outline: none; outline: none;
border-color: #60a5fa; border-color: #60a5fa;
@@ -129,6 +165,16 @@ const { queryStats } = apiStatsStore
0 0 0 3px rgba(96, 165, 250, 0.2), 0 0 0 3px rgba(96, 165, 250, 0.2),
0 10px 15px -3px rgba(0, 0, 0, 0.1); 0 10px 15px -3px rgba(0, 0, 0, 0.1);
background: white; background: white;
color: #1f2937;
}
:global(.dark) .wide-card-input:focus {
border-color: #60a5fa;
box-shadow:
0 0 0 3px rgba(96, 165, 250, 0.15),
0 10px 15px -3px rgba(0, 0, 0, 0.4);
background: rgba(31, 41, 55, 0.95);
color: #f3f4f6;
} }
/* 按钮样式 */ /* 按钮样式 */
@@ -172,8 +218,8 @@ const { queryStats } = apiStatsStore
/* 安全提示样式 */ /* 安全提示样式 */
.security-notice { .security-notice {
background: rgba(255, 255, 255, 0.15); background: rgba(255, 255, 255, 0.5);
border: 1px solid rgba(255, 255, 255, 0.25); border: 1px solid rgba(255, 255, 255, 0.4);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
border-radius: 8px; border-radius: 8px;
padding: 12px 16px; padding: 12px 16px;
@@ -182,9 +228,22 @@ const { queryStats } = apiStatsStore
transition: all 0.3s ease; transition: all 0.3s ease;
} }
:global(.dark) .security-notice {
background: rgba(31, 41, 55, 0.8) !important;
border: 1px solid rgba(75, 85, 99, 0.5) !important;
color: #d1d5db !important;
}
.security-notice:hover { .security-notice:hover {
background: rgba(255, 255, 255, 0.2); background: rgba(255, 255, 255, 0.6);
border-color: rgba(255, 255, 255, 0.35); border-color: rgba(255, 255, 255, 0.5);
color: #1f2937;
}
:global(.dark) .security-notice:hover {
background: rgba(31, 41, 55, 0.9) !important;
border-color: rgba(75, 85, 99, 0.6) !important;
color: #e5e7eb !important;
} }
.security-notice .fas.fa-shield-alt { .security-notice .fas.fa-shield-alt {

View File

@@ -2,7 +2,9 @@
<div> <div>
<!-- 限制配置 --> <!-- 限制配置 -->
<div class="card p-4 md:p-6"> <div class="card p-4 md:p-6">
<h3 class="mb-3 flex items-center text-lg font-bold text-gray-900 md:mb-4 md:text-xl"> <h3
class="mb-3 flex items-center text-lg font-bold text-gray-900 dark:text-gray-100 md:mb-4 md:text-xl"
>
<i class="fas fa-shield-alt mr-2 text-sm text-red-500 md:mr-3 md:text-base" /> <i class="fas fa-shield-alt mr-2 text-sm text-red-500 md:mr-3 md:text-base" />
限制配置 限制配置
</h3> </h3>
@@ -10,8 +12,10 @@
<!-- 每日费用限制 --> <!-- 每日费用限制 -->
<div> <div>
<div class="mb-2 flex items-center justify-between"> <div class="mb-2 flex items-center justify-between">
<span class="text-sm font-medium text-gray-600 md:text-base">每日费用限制</span> <span class="text-sm font-medium text-gray-600 dark:text-gray-400 md:text-base"
<span class="text-xs text-gray-500 md:text-sm"> >每日费用限制</span
>
<span class="text-xs text-gray-500 dark:text-gray-400 md:text-sm">
<span v-if="statsData.limits.dailyCostLimit > 0"> <span v-if="statsData.limits.dailyCostLimit > 0">
${{ statsData.limits.currentDailyCost.toFixed(4) }} / ${{ ${{ statsData.limits.currentDailyCost.toFixed(4) }} / ${{
statsData.limits.dailyCostLimit.toFixed(2) statsData.limits.dailyCostLimit.toFixed(2)
@@ -24,7 +28,7 @@
</div> </div>
<div <div
v-if="statsData.limits.dailyCostLimit > 0" v-if="statsData.limits.dailyCostLimit > 0"
class="h-2 w-full rounded-full bg-gray-200" class="h-2 w-full rounded-full bg-gray-200 dark:bg-gray-700"
> >
<div <div
class="h-2 rounded-full transition-all duration-300" class="h-2 rounded-full transition-all duration-300"
@@ -58,16 +62,16 @@
:window-start-time="statsData.limits.windowStartTime" :window-start-time="statsData.limits.windowStartTime"
/> />
<div class="mt-2 text-xs text-gray-500"> <div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
<i class="fas fa-info-circle mr-1" /> <i class="fas fa-info-circle mr-1" />
请求次数和Token使用量为"或"的关系,任一达到限制即触发限流 请求次数和Token使用量为"或"的关系,任一达到限制即触发限流
</div> </div>
</div> </div>
<!-- 其他限制信息 --> <!-- 其他限制信息 -->
<div class="space-y-2 border-t border-gray-100 pt-2"> <div class="space-y-2 border-t border-gray-100 pt-2 dark:border-gray-700">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-sm text-gray-600 md:text-base">并发限制</span> <span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">并发限制</span>
<span class="text-sm font-medium text-gray-900 md:text-base"> <span class="text-sm font-medium text-gray-900 md:text-base">
<span v-if="statsData.limits.concurrencyLimit > 0"> <span v-if="statsData.limits.concurrencyLimit > 0">
{{ statsData.limits.concurrencyLimit }} {{ statsData.limits.concurrencyLimit }}
@@ -78,7 +82,7 @@
</span> </span>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-sm text-gray-600 md:text-base">模型限制</span> <span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">模型限制</span>
<span class="text-sm font-medium text-gray-900 md:text-base"> <span class="text-sm font-medium text-gray-900 md:text-base">
<span <span
v-if=" v-if="
@@ -97,7 +101,7 @@
</span> </span>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-sm text-gray-600 md:text-base">客户端限制</span> <span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">客户端限制</span>
<span class="text-sm font-medium text-gray-900 md:text-base"> <span class="text-sm font-medium text-gray-900 md:text-base">
<span <span
v-if=" v-if="
@@ -129,7 +133,9 @@
" "
class="card mt-4 p-4 md:mt-6 md:p-6" class="card mt-4 p-4 md:mt-6 md:p-6"
> >
<h3 class="mb-3 flex items-center text-lg font-bold text-gray-900 md:mb-4 md:text-xl"> <h3
class="mb-3 flex items-center text-lg font-bold text-gray-900 dark:text-gray-100 md:mb-4 md:text-xl"
>
<i class="fas fa-list-alt mr-2 text-sm text-amber-500 md:mr-3 md:text-base" /> <i class="fas fa-list-alt mr-2 text-sm text-amber-500 md:mr-3 md:text-base" />
详细限制信息 详细限制信息
</h3> </h3>
@@ -141,9 +147,11 @@
statsData.restrictions.enableModelRestriction && statsData.restrictions.enableModelRestriction &&
statsData.restrictions.restrictedModels.length > 0 statsData.restrictions.restrictedModels.length > 0
" "
class="rounded-lg border border-amber-200 bg-amber-50 p-3 md:p-4" class="rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-800 dark:bg-amber-900/20 md:p-4"
>
<h4
class="mb-2 flex items-center text-sm font-bold text-amber-800 dark:text-amber-300 md:mb-3 md:text-base"
> >
<h4 class="mb-2 flex items-center text-sm font-bold text-amber-800 md:mb-3 md:text-base">
<i class="fas fa-robot mr-1 text-xs md:mr-2 md:text-sm" /> <i class="fas fa-robot mr-1 text-xs md:mr-2 md:text-sm" />
受限模型列表 受限模型列表
</h4> </h4>
@@ -151,13 +159,13 @@
<div <div
v-for="model in statsData.restrictions.restrictedModels" v-for="model in statsData.restrictions.restrictedModels"
:key="model" :key="model"
class="rounded border border-amber-200 bg-white px-2 py-1 text-xs md:px-3 md:py-2 md:text-sm" class="rounded border border-amber-200 bg-white px-2 py-1 text-xs dark:border-amber-700 dark:bg-gray-800 md:px-3 md:py-2 md:text-sm"
> >
<i class="fas fa-ban mr-1 text-xs text-red-500 md:mr-2" /> <i class="fas fa-ban mr-1 text-xs text-red-500 md:mr-2" />
<span class="break-all text-gray-800">{{ model }}</span> <span class="break-all text-gray-800 dark:text-gray-200">{{ model }}</span>
</div> </div>
</div> </div>
<p class="mt-2 text-xs text-amber-700 md:mt-3"> <p class="mt-2 text-xs text-amber-700 dark:text-amber-400 md:mt-3">
<i class="fas fa-info-circle mr-1" /> <i class="fas fa-info-circle mr-1" />
此 API Key 不能访问以上列出的模型 此 API Key 不能访问以上列出的模型
</p> </p>
@@ -169,9 +177,11 @@
statsData.restrictions.enableClientRestriction && statsData.restrictions.enableClientRestriction &&
statsData.restrictions.allowedClients.length > 0 statsData.restrictions.allowedClients.length > 0
" "
class="rounded-lg border border-blue-200 bg-blue-50 p-3 md:p-4" class="rounded-lg border border-blue-200 bg-blue-50 p-3 dark:border-blue-800 dark:bg-blue-900/20 md:p-4"
>
<h4
class="mb-2 flex items-center text-sm font-bold text-blue-800 dark:text-blue-300 md:mb-3 md:text-base"
> >
<h4 class="mb-2 flex items-center text-sm font-bold text-blue-800 md:mb-3 md:text-base">
<i class="fas fa-desktop mr-1 text-xs md:mr-2 md:text-sm" /> <i class="fas fa-desktop mr-1 text-xs md:mr-2 md:text-sm" />
允许的客户端 允许的客户端
</h4> </h4>
@@ -179,13 +189,13 @@
<div <div
v-for="client in statsData.restrictions.allowedClients" v-for="client in statsData.restrictions.allowedClients"
:key="client" :key="client"
class="rounded border border-blue-200 bg-white px-2 py-1 text-xs md:px-3 md:py-2 md:text-sm" class="rounded border border-blue-200 bg-white px-2 py-1 text-xs dark:border-blue-700 dark:bg-gray-800 md:px-3 md:py-2 md:text-sm"
> >
<i class="fas fa-check mr-1 text-xs text-green-500 md:mr-2" /> <i class="fas fa-check mr-1 text-xs text-green-500 md:mr-2" />
<span class="break-all text-gray-800">{{ client }}</span> <span class="break-all text-gray-800 dark:text-gray-200">{{ client }}</span>
</div> </div>
</div> </div>
<p class="mt-2 text-xs text-blue-700 md:mt-3"> <p class="mt-2 text-xs text-blue-700 dark:text-blue-400 md:mt-3">
<i class="fas fa-info-circle mr-1" /> <i class="fas fa-info-circle mr-1" />
API Key 只能被以上列出的客户端使用 API Key 只能被以上列出的客户端使用
</p> </p>
@@ -222,11 +232,11 @@ const getDailyCostProgressColor = () => {
</script> </script>
<style scoped> <style scoped>
/* 卡片样式 */ /* 卡片样式 - 使用CSS变量 */
.card { .card {
background: rgba(255, 255, 255, 0.95); background: var(--surface-color);
border-radius: 16px; border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.2); border: 1px solid var(--border-color);
box-shadow: box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05); 0 4px 6px -2px rgba(0, 0, 0, 0.05);
@@ -251,4 +261,10 @@ const getDailyCostProgressColor = () => {
0 20px 25px -5px rgba(0, 0, 0, 0.15), 0 20px 25px -5px rgba(0, 0, 0, 0.15),
0 10px 10px -5px rgba(0, 0, 0, 0.08); 0 10px 10px -5px rgba(0, 0, 0, 0.08);
} }
:global(.dark) .card:hover {
box-shadow:
0 20px 25px -5px rgba(0, 0, 0, 0.5),
0 10px 10px -5px rgba(0, 0, 0, 0.35);
}
</style> </style>

View File

@@ -2,13 +2,13 @@
<div class="card p-4 md:p-6"> <div class="card p-4 md:p-6">
<div class="mb-4 md:mb-6"> <div class="mb-4 md:mb-6">
<h3 <h3
class="flex flex-col text-lg font-bold text-gray-900 sm:flex-row sm:items-center md:text-xl" class="flex flex-col text-lg font-bold text-gray-900 dark:text-gray-100 sm:flex-row sm:items-center md:text-xl"
> >
<span class="flex items-center"> <span class="flex items-center">
<i class="fas fa-robot mr-2 text-sm text-indigo-500 md:mr-3 md:text-base" /> <i class="fas fa-robot mr-2 text-sm text-indigo-500 md:mr-3 md:text-base" />
模型使用统计 模型使用统计
</span> </span>
<span class="text-xs font-normal text-gray-600 sm:ml-2 md:text-sm" <span class="text-xs font-normal text-gray-600 dark:text-gray-400 sm:ml-2 md:text-sm"
>({{ statsPeriod === 'daily' ? '今日' : '本月' }})</span >({{ statsPeriod === 'daily' ? '今日' : '本月' }})</span
> >
</h3> </h3>
@@ -16,8 +16,10 @@
<!-- 模型统计加载状态 --> <!-- 模型统计加载状态 -->
<div v-if="modelStatsLoading" class="py-6 text-center md:py-8"> <div v-if="modelStatsLoading" class="py-6 text-center md:py-8">
<i class="fas fa-spinner loading-spinner mb-2 text-xl text-gray-600 md:text-2xl" /> <i
<p class="text-sm text-gray-600 md:text-base">加载模型统计数据中...</p> class="fas fa-spinner loading-spinner mb-2 text-xl text-gray-600 dark:text-gray-400 md:text-2xl"
/>
<p class="text-sm text-gray-600 dark:text-gray-400 md:text-base">加载模型统计数据中...</p>
</div> </div>
<!-- 模型统计数据 --> <!-- 模型统计数据 -->
@@ -25,41 +27,43 @@
<div v-for="(model, index) in modelStats" :key="index" class="model-usage-item"> <div v-for="(model, index) in modelStats" :key="index" class="model-usage-item">
<div class="mb-2 flex items-start justify-between md:mb-3"> <div class="mb-2 flex items-start justify-between md:mb-3">
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<h4 class="break-all text-base font-bold text-gray-900 md:text-lg"> <h4 class="break-all text-base font-bold text-gray-900 dark:text-gray-100 md:text-lg">
{{ model.model }} {{ model.model }}
</h4> </h4>
<p class="text-xs text-gray-600 md:text-sm">{{ model.requests }} 次请求</p> <p class="text-xs text-gray-600 dark:text-gray-400 md:text-sm">
{{ model.requests }} 次请求
</p>
</div> </div>
<div class="ml-3 flex-shrink-0 text-right"> <div class="ml-3 flex-shrink-0 text-right">
<div class="text-base font-bold text-green-600 md:text-lg"> <div class="text-base font-bold text-green-600 md:text-lg">
{{ model.formatted?.total || '$0.000000' }} {{ model.formatted?.total || '$0.000000' }}
</div> </div>
<div class="text-xs text-gray-600 md:text-sm">总费用</div> <div class="text-xs text-gray-600 dark:text-gray-400 md:text-sm">总费用</div>
</div> </div>
</div> </div>
<div class="grid grid-cols-2 gap-2 text-xs md:grid-cols-4 md:gap-3 md:text-sm"> <div class="grid grid-cols-2 gap-2 text-xs md:grid-cols-4 md:gap-3 md:text-sm">
<div class="rounded bg-gray-50 p-2"> <div class="rounded bg-gray-50 p-2 dark:bg-gray-700">
<div class="text-gray-600">输入 Token</div> <div class="text-gray-600 dark:text-gray-400">输入 Token</div>
<div class="font-medium text-gray-900"> <div class="font-medium text-gray-900 dark:text-gray-100">
{{ formatNumber(model.inputTokens) }} {{ formatNumber(model.inputTokens) }}
</div> </div>
</div> </div>
<div class="rounded bg-gray-50 p-2"> <div class="rounded bg-gray-50 p-2 dark:bg-gray-700">
<div class="text-gray-600">输出 Token</div> <div class="text-gray-600 dark:text-gray-400">输出 Token</div>
<div class="font-medium text-gray-900"> <div class="font-medium text-gray-900 dark:text-gray-100">
{{ formatNumber(model.outputTokens) }} {{ formatNumber(model.outputTokens) }}
</div> </div>
</div> </div>
<div class="rounded bg-gray-50 p-2"> <div class="rounded bg-gray-50 p-2 dark:bg-gray-700">
<div class="text-gray-600">缓存创建</div> <div class="text-gray-600 dark:text-gray-400">缓存创建</div>
<div class="font-medium text-gray-900"> <div class="font-medium text-gray-900 dark:text-gray-100">
{{ formatNumber(model.cacheCreateTokens) }} {{ formatNumber(model.cacheCreateTokens) }}
</div> </div>
</div> </div>
<div class="rounded bg-gray-50 p-2"> <div class="rounded bg-gray-50 p-2 dark:bg-gray-700">
<div class="text-gray-600">缓存读取</div> <div class="text-gray-600 dark:text-gray-400">缓存读取</div>
<div class="font-medium text-gray-900"> <div class="font-medium text-gray-900 dark:text-gray-100">
{{ formatNumber(model.cacheReadTokens) }} {{ formatNumber(model.cacheReadTokens) }}
</div> </div>
</div> </div>
@@ -68,7 +72,7 @@
</div> </div>
<!-- 无模型数据 --> <!-- 无模型数据 -->
<div v-else class="py-6 text-center text-gray-500 md:py-8"> <div v-else class="py-6 text-center text-gray-500 dark:text-gray-400 md:py-8">
<i class="fas fa-chart-pie mb-3 text-2xl md:text-3xl" /> <i class="fas fa-chart-pie mb-3 text-2xl md:text-3xl" />
<p class="text-sm md:text-base"> <p class="text-sm md:text-base">
暂无{{ statsPeriod === 'daily' ? '今日' : '本月' }}模型使用数据 暂无{{ statsPeriod === 'daily' ? '今日' : '本月' }}模型使用数据
@@ -104,11 +108,11 @@ const formatNumber = (num) => {
</script> </script>
<style scoped> <style scoped>
/* 卡片样式 */ /* 卡片样式 - 使用CSS变量 */
.card { .card {
background: rgba(255, 255, 255, 0.95); background: var(--surface-color);
border-radius: 16px; border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.2); border: 1px solid var(--border-color);
box-shadow: box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05); 0 4px 6px -2px rgba(0, 0, 0, 0.05);
@@ -134,10 +138,16 @@ const formatNumber = (num) => {
0 10px 10px -5px rgba(0, 0, 0, 0.08); 0 10px 10px -5px rgba(0, 0, 0, 0.08);
} }
/* 模型使用项样式 */ :global(.dark) .card:hover {
box-shadow:
0 20px 25px -5px rgba(0, 0, 0, 0.5),
0 10px 10px -5px rgba(0, 0, 0, 0.35);
}
/* 模型使用项样式 - 使用CSS变量 */
.model-usage-item { .model-usage-item {
background: rgba(255, 255, 255, 0.95); background: var(--surface-color);
border: 1px solid rgba(255, 255, 255, 0.2); border: 1px solid var(--border-color);
border-radius: 12px; border-radius: 12px;
padding: 12px; padding: 12px;
transition: all 0.3s ease; transition: all 0.3s ease;
@@ -169,6 +179,13 @@ const formatNumber = (num) => {
border-color: rgba(255, 255, 255, 0.3); border-color: rgba(255, 255, 255, 0.3);
} }
:global(.dark) .model-usage-item:hover {
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.4),
0 4px 6px -2px rgba(0, 0, 0, 0.25);
border-color: rgba(75, 85, 99, 0.6);
}
/* 加载动画 */ /* 加载动画 */
.loading-spinner { .loading-spinner {
animation: spin 1s linear infinite; animation: spin 1s linear infinite;

View File

@@ -2,19 +2,22 @@
<div class="mb-6 grid grid-cols-1 gap-4 md:mb-8 md:gap-6 lg:grid-cols-2"> <div class="mb-6 grid grid-cols-1 gap-4 md:mb-8 md:gap-6 lg:grid-cols-2">
<!-- API Key 基本信息 --> <!-- API Key 基本信息 -->
<div class="card p-4 md:p-6"> <div class="card p-4 md:p-6">
<h3 class="mb-3 flex items-center text-lg font-bold text-gray-900 md:mb-4 md:text-xl"> <h3
class="mb-3 flex items-center text-lg font-bold text-gray-900 dark:text-gray-100 md:mb-4 md:text-xl"
>
<i class="fas fa-info-circle mr-2 text-sm text-blue-500 md:mr-3 md:text-base" /> <i class="fas fa-info-circle mr-2 text-sm text-blue-500 md:mr-3 md:text-base" />
API Key 信息 API Key 信息
</h3> </h3>
<div class="space-y-2 md:space-y-3"> <div class="space-y-2 md:space-y-3">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-sm text-gray-600 md:text-base">名称</span> <span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">名称</span>
<span class="break-all text-sm font-medium text-gray-900 md:text-base">{{ <span
statsData.name class="break-all text-sm font-medium text-gray-900 dark:text-gray-100 md:text-base"
}}</span> >{{ statsData.name }}</span
>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-sm text-gray-600 md:text-base">状态</span> <span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">状态</span>
<span <span
class="text-sm font-medium md:text-base" class="text-sm font-medium md:text-base"
:class="statsData.isActive ? 'text-green-600' : 'text-red-600'" :class="statsData.isActive ? 'text-green-600' : 'text-red-600'"
@@ -27,19 +30,22 @@
</span> </span>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-sm text-gray-600 md:text-base">权限</span> <span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">权限</span>
<span class="text-sm font-medium text-gray-900 md:text-base">{{ <span class="text-sm font-medium text-gray-900 dark:text-gray-100 md:text-base">{{
formatPermissions(statsData.permissions) formatPermissions(statsData.permissions)
}}</span> }}</span>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-sm text-gray-600 md:text-base">创建时间</span> <span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">创建时间</span>
<span class="break-all text-xs font-medium text-gray-900 md:text-base">{{ <span
formatDate(statsData.createdAt) class="break-all text-xs font-medium text-gray-900 dark:text-gray-100 md:text-base"
}}</span> >{{ formatDate(statsData.createdAt) }}</span
>
</div> </div>
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<span class="mt-1 flex-shrink-0 text-sm text-gray-600 md:text-base">过期时间</span> <span class="mt-1 flex-shrink-0 text-sm text-gray-600 dark:text-gray-400 md:text-base"
>过期时间</span
>
<div v-if="statsData.expiresAt" class="text-right"> <div v-if="statsData.expiresAt" class="text-right">
<div <div
v-if="isApiKeyExpired(statsData.expiresAt)" v-if="isApiKeyExpired(statsData.expiresAt)"
@@ -55,11 +61,14 @@
<i class="fas fa-clock mr-1 text-xs md:text-sm" /> <i class="fas fa-clock mr-1 text-xs md:text-sm" />
{{ formatExpireDate(statsData.expiresAt) }} {{ formatExpireDate(statsData.expiresAt) }}
</div> </div>
<div v-else class="break-all text-xs font-medium text-gray-900 md:text-base"> <div
v-else
class="break-all text-xs font-medium text-gray-900 dark:text-gray-100 md:text-base"
>
{{ formatExpireDate(statsData.expiresAt) }} {{ formatExpireDate(statsData.expiresAt) }}
</div> </div>
</div> </div>
<div v-else class="text-sm font-medium text-gray-400 md:text-base"> <div v-else class="text-sm font-medium text-gray-400 dark:text-gray-500 md:text-base">
<i class="fas fa-infinity mr-1 text-xs md:text-sm" /> <i class="fas fa-infinity mr-1 text-xs md:text-sm" />
永不过期 永不过期
</div> </div>
@@ -70,13 +79,13 @@
<!-- 使用统计概览 --> <!-- 使用统计概览 -->
<div class="card p-4 md:p-6"> <div class="card p-4 md:p-6">
<h3 <h3
class="mb-3 flex flex-col text-lg font-bold text-gray-900 sm:flex-row sm:items-center md:mb-4 md:text-xl" class="mb-3 flex flex-col text-lg font-bold text-gray-900 dark:text-gray-100 sm:flex-row sm:items-center md:mb-4 md:text-xl"
> >
<span class="flex items-center"> <span class="flex items-center">
<i class="fas fa-chart-bar mr-2 text-sm text-green-500 md:mr-3 md:text-base" /> <i class="fas fa-chart-bar mr-2 text-sm text-green-500 md:mr-3 md:text-base" />
使用统计概览 使用统计概览
</span> </span>
<span class="text-xs font-normal text-gray-600 sm:ml-2 md:text-sm" <span class="text-xs font-normal text-gray-600 dark:text-gray-400 sm:ml-2 md:text-sm"
>({{ statsPeriod === 'daily' ? '今日' : '本月' }})</span >({{ statsPeriod === 'daily' ? '今日' : '本月' }})</span
> >
</h3> </h3>
@@ -85,7 +94,7 @@
<div class="text-lg font-bold text-green-600 md:text-3xl"> <div class="text-lg font-bold text-green-600 md:text-3xl">
{{ formatNumber(currentPeriodData.requests) }} {{ formatNumber(currentPeriodData.requests) }}
</div> </div>
<div class="text-xs text-gray-600 md:text-sm"> <div class="text-xs text-gray-600 dark:text-gray-400 md:text-sm">
{{ statsPeriod === 'daily' ? '今日' : '本月' }}请求数 {{ statsPeriod === 'daily' ? '今日' : '本月' }}请求数
</div> </div>
</div> </div>
@@ -93,7 +102,7 @@
<div class="text-lg font-bold text-blue-600 md:text-3xl"> <div class="text-lg font-bold text-blue-600 md:text-3xl">
{{ formatNumber(currentPeriodData.allTokens) }} {{ formatNumber(currentPeriodData.allTokens) }}
</div> </div>
<div class="text-xs text-gray-600 md:text-sm"> <div class="text-xs text-gray-600 dark:text-gray-400 md:text-sm">
{{ statsPeriod === 'daily' ? '今日' : '本月' }}Token数 {{ statsPeriod === 'daily' ? '今日' : '本月' }}Token数
</div> </div>
</div> </div>
@@ -101,7 +110,7 @@
<div class="text-lg font-bold text-purple-600 md:text-3xl"> <div class="text-lg font-bold text-purple-600 md:text-3xl">
{{ currentPeriodData.formattedCost || '$0.000000' }} {{ currentPeriodData.formattedCost || '$0.000000' }}
</div> </div>
<div class="text-xs text-gray-600 md:text-sm"> <div class="text-xs text-gray-600 dark:text-gray-400 md:text-sm">
{{ statsPeriod === 'daily' ? '今日' : '本月' }}费用 {{ statsPeriod === 'daily' ? '今日' : '本月' }}费用
</div> </div>
</div> </div>
@@ -109,7 +118,7 @@
<div class="text-lg font-bold text-yellow-600 md:text-3xl"> <div class="text-lg font-bold text-yellow-600 md:text-3xl">
{{ formatNumber(currentPeriodData.inputTokens) }} {{ formatNumber(currentPeriodData.inputTokens) }}
</div> </div>
<div class="text-xs text-gray-600 md:text-sm"> <div class="text-xs text-gray-600 dark:text-gray-400 md:text-sm">
{{ statsPeriod === 'daily' ? '今日' : '本月' }}输入Token {{ statsPeriod === 'daily' ? '今日' : '本月' }}输入Token
</div> </div>
</div> </div>
@@ -197,11 +206,11 @@ const formatPermissions = (permissions) => {
</script> </script>
<style scoped> <style scoped>
/* 卡片样式 */ /* 卡片样式 - 使用CSS变量 */
.card { .card {
background: rgba(255, 255, 255, 0.95); background: var(--surface-color);
border-radius: 16px; border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.2); border: 1px solid var(--border-color);
box-shadow: box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05); 0 4px 6px -2px rgba(0, 0, 0, 0.05);
@@ -227,11 +236,17 @@ const formatPermissions = (permissions) => {
0 10px 10px -5px rgba(0, 0, 0, 0.08); 0 10px 10px -5px rgba(0, 0, 0, 0.08);
} }
/* 统计卡片样式 */ :global(.dark) .card:hover {
box-shadow:
0 20px 25px -5px rgba(0, 0, 0, 0.5),
0 10px 10px -5px rgba(0, 0, 0, 0.35);
}
/* 统计卡片样式 - 使用CSS变量 */
.stat-card { .stat-card {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.95) 0%, rgba(255, 255, 255, 0.8) 100%); background: linear-gradient(135deg, var(--surface-color) 0%, var(--glass-strong-color) 100%);
border-radius: 16px; border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.3); border: 1px solid var(--border-color);
padding: 16px; padding: 16px;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
@@ -264,6 +279,12 @@ const formatPermissions = (permissions) => {
0 10px 10px -5px rgba(0, 0, 0, 0.04); 0 10px 10px -5px rgba(0, 0, 0, 0.04);
} }
:global(.dark) .stat-card:hover {
box-shadow:
0 20px 25px -5px rgba(0, 0, 0, 0.4),
0 10px 10px -5px rgba(0, 0, 0, 0.25);
}
.stat-card:hover::before { .stat-card:hover::before {
opacity: 1; opacity: 1;
} }

View File

@@ -1,56 +1,56 @@
<template> <template>
<div class="card p-4 md:p-6"> <div class="card p-4 md:p-6">
<h3 <h3
class="mb-3 flex flex-col text-lg font-bold text-gray-900 sm:flex-row sm:items-center md:mb-4 md:text-xl" class="mb-3 flex flex-col text-lg font-bold text-gray-900 dark:text-gray-100 sm:flex-row sm:items-center md:mb-4 md:text-xl"
> >
<span class="flex items-center"> <span class="flex items-center">
<i class="fas fa-coins mr-2 text-sm text-yellow-500 md:mr-3 md:text-base" /> <i class="fas fa-coins mr-2 text-sm text-yellow-500 md:mr-3 md:text-base" />
Token 使用分布 Token 使用分布
</span> </span>
<span class="text-xs font-normal text-gray-600 sm:ml-2 md:text-sm" <span class="text-xs font-normal text-gray-600 dark:text-gray-400 sm:ml-2 md:text-sm"
>({{ statsPeriod === 'daily' ? '今日' : '本月' }})</span >({{ statsPeriod === 'daily' ? '今日' : '本月' }})</span
> >
</h3> </h3>
<div class="space-y-2 md:space-y-3"> <div class="space-y-2 md:space-y-3">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="flex items-center text-sm text-gray-600 md:text-base"> <span class="flex items-center text-sm text-gray-600 dark:text-gray-400 md:text-base">
<i class="fas fa-arrow-right mr-1 text-xs text-green-500 md:mr-2 md:text-sm" /> <i class="fas fa-arrow-right mr-1 text-xs text-green-500 md:mr-2 md:text-sm" />
输入 Token 输入 Token
</span> </span>
<span class="text-sm font-medium text-gray-900 md:text-base">{{ <span class="text-sm font-medium text-gray-900 dark:text-gray-100 md:text-base">{{
formatNumber(currentPeriodData.inputTokens) formatNumber(currentPeriodData.inputTokens)
}}</span> }}</span>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="flex items-center text-sm text-gray-600 md:text-base"> <span class="flex items-center text-sm text-gray-600 dark:text-gray-400 md:text-base">
<i class="fas fa-arrow-left mr-1 text-xs text-blue-500 md:mr-2 md:text-sm" /> <i class="fas fa-arrow-left mr-1 text-xs text-blue-500 md:mr-2 md:text-sm" />
输出 Token 输出 Token
</span> </span>
<span class="text-sm font-medium text-gray-900 md:text-base">{{ <span class="text-sm font-medium text-gray-900 dark:text-gray-100 md:text-base">{{
formatNumber(currentPeriodData.outputTokens) formatNumber(currentPeriodData.outputTokens)
}}</span> }}</span>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="flex items-center text-sm text-gray-600 md:text-base"> <span class="flex items-center text-sm text-gray-600 dark:text-gray-400 md:text-base">
<i class="fas fa-save mr-1 text-xs text-purple-500 md:mr-2 md:text-sm" /> <i class="fas fa-save mr-1 text-xs text-purple-500 md:mr-2 md:text-sm" />
缓存创建 Token 缓存创建 Token
</span> </span>
<span class="text-sm font-medium text-gray-900 md:text-base">{{ <span class="text-sm font-medium text-gray-900 dark:text-gray-100 md:text-base">{{
formatNumber(currentPeriodData.cacheCreateTokens) formatNumber(currentPeriodData.cacheCreateTokens)
}}</span> }}</span>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="flex items-center text-sm text-gray-600 md:text-base"> <span class="flex items-center text-sm text-gray-600 dark:text-gray-400 md:text-base">
<i class="fas fa-download mr-1 text-xs text-orange-500 md:mr-2 md:text-sm" /> <i class="fas fa-download mr-1 text-xs text-orange-500 md:mr-2 md:text-sm" />
缓存读取 Token 缓存读取 Token
</span> </span>
<span class="text-sm font-medium text-gray-900 md:text-base">{{ <span class="text-sm font-medium text-gray-900 dark:text-gray-100 md:text-base">{{
formatNumber(currentPeriodData.cacheReadTokens) formatNumber(currentPeriodData.cacheReadTokens)
}}</span> }}</span>
</div> </div>
</div> </div>
<div class="mt-3 border-t border-gray-200 pt-3 md:mt-4 md:pt-4"> <div class="mt-3 border-t border-gray-200 pt-3 dark:border-gray-700 md:mt-4 md:pt-4">
<div class="flex items-center justify-between font-bold text-gray-900"> <div class="flex items-center justify-between font-bold text-gray-900 dark:text-gray-100">
<span class="text-sm md:text-base" <span class="text-sm md:text-base"
>{{ statsPeriod === 'daily' ? '今日' : '本月' }}总计</span >{{ statsPeriod === 'daily' ? '今日' : '本月' }}总计</span
> >
@@ -87,11 +87,11 @@ const formatNumber = (num) => {
</script> </script>
<style scoped> <style scoped>
/* 卡片样式 */ /* 卡片样式 - 使用CSS变量 */
.card { .card {
background: rgba(255, 255, 255, 0.95); background: var(--surface-color);
border-radius: 16px; border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.2); border: 1px solid var(--border-color);
box-shadow: box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05); 0 4px 6px -2px rgba(0, 0, 0, 0.05);
@@ -116,4 +116,10 @@ const formatNumber = (num) => {
0 20px 25px -5px rgba(0, 0, 0, 0.15), 0 20px 25px -5px rgba(0, 0, 0, 0.15),
0 10px 10px -5px rgba(0, 0, 0, 0.08); 0 10px 10px -5px rgba(0, 0, 0, 0.08);
} }
:global(.dark) .card:hover {
box-shadow:
0 20px 25px -5px rgba(0, 0, 0, 0.5),
0 10px 10px -5px rgba(0, 0, 0, 0.35);
}
</style> </style>

View File

@@ -2,13 +2,18 @@
<div ref="triggerRef" class="relative"> <div ref="triggerRef" class="relative">
<!-- 选择器主体 --> <!-- 选择器主体 -->
<div <div
class="form-input flex w-full cursor-pointer items-center justify-between" class="form-input flex w-full cursor-pointer items-center justify-between dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
:class="{ 'opacity-50': disabled }" :class="{ 'opacity-50': disabled }"
@click="!disabled && toggleDropdown()" @click="!disabled && toggleDropdown()"
> >
<span :class="modelValue ? 'text-gray-900' : 'text-gray-500'">{{ selectedLabel }}</span> <span
:class="
modelValue ? 'text-gray-900 dark:text-gray-200' : 'text-gray-500 dark:text-gray-400'
"
>{{ selectedLabel }}</span
>
<i <i
class="fas fa-chevron-down text-gray-400 transition-transform duration-200" class="fas fa-chevron-down text-gray-400 transition-transform duration-200 dark:text-gray-500"
:class="{ 'rotate-180': showDropdown }" :class="{ 'rotate-180': showDropdown }"
/> />
</div> </div>
@@ -26,27 +31,27 @@
<div <div
v-if="showDropdown" v-if="showDropdown"
ref="dropdownRef" ref="dropdownRef"
class="absolute z-50 flex flex-col rounded-lg border border-gray-200 bg-white shadow-lg" class="absolute z-50 flex flex-col rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-600 dark:bg-gray-800"
:style="dropdownStyle" :style="dropdownStyle"
> >
<!-- 搜索框 --> <!-- 搜索框 -->
<div class="flex-shrink-0 border-b border-gray-200 p-3"> <div class="flex-shrink-0 border-b border-gray-200 p-3 dark:border-gray-600">
<div class="relative"> <div class="relative">
<input <input
ref="searchInput" ref="searchInput"
v-model="searchQuery" v-model="searchQuery"
class="form-input w-full text-sm" class="form-input w-full text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
placeholder="搜索账号名称..." placeholder="搜索账号名称..."
style="padding-left: 40px; padding-right: 36px" style="padding-left: 40px; padding-right: 36px"
type="text" type="text"
@input="handleSearch" @input="handleSearch"
/> />
<i <i
class="fas fa-search pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-gray-400" class="fas fa-search pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-gray-400 dark:text-gray-500"
/> />
<button <button
v-if="searchQuery" v-if="searchQuery"
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600" class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-400"
type="button" type="button"
@click="clearSearch" @click="clearSearch"
> >
@@ -59,59 +64,67 @@
<div class="custom-scrollbar flex-1 overflow-y-auto"> <div class="custom-scrollbar flex-1 overflow-y-auto">
<!-- 默认选项 --> <!-- 默认选项 -->
<div <div
class="cursor-pointer px-4 py-2 transition-colors hover:bg-gray-50" class="cursor-pointer px-4 py-2 transition-colors hover:bg-gray-50 dark:hover:bg-gray-700"
:class="{ 'bg-blue-50': !modelValue }" :class="{ 'bg-blue-50 dark:bg-blue-900/20': !modelValue }"
@click="selectAccount(null)" @click="selectAccount(null)"
> >
<span class="text-gray-700">{{ defaultOptionText }}</span> <span class="text-gray-700 dark:text-gray-300">{{ defaultOptionText }}</span>
</div> </div>
<!-- 分组选项 --> <!-- 分组选项 -->
<div v-if="filteredGroups.length > 0"> <div v-if="filteredGroups.length > 0">
<div class="bg-gray-50 px-4 py-2 text-xs font-semibold text-gray-500">调度分组</div> <div
class="bg-gray-50 px-4 py-2 text-xs font-semibold text-gray-500 dark:bg-gray-700 dark:text-gray-400"
>
调度分组
</div>
<div <div
v-for="group in filteredGroups" v-for="group in filteredGroups"
:key="`group:${group.id}`" :key="`group:${group.id}`"
class="cursor-pointer px-4 py-2 transition-colors hover:bg-gray-50" class="cursor-pointer px-4 py-2 transition-colors hover:bg-gray-50 dark:hover:bg-gray-700"
:class="{ 'bg-blue-50': modelValue === `group:${group.id}` }" :class="{ 'bg-blue-50 dark:bg-blue-900/20': modelValue === `group:${group.id}` }"
@click="selectAccount(`group:${group.id}`)" @click="selectAccount(`group:${group.id}`)"
> >
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-gray-700">{{ group.name }}</span> <span class="text-gray-700 dark:text-gray-300">{{ group.name }}</span>
<span class="text-xs text-gray-500">{{ group.memberCount || 0 }} 个成员</span> <span class="text-xs text-gray-500 dark:text-gray-400"
>{{ group.memberCount || 0 }} 个成员</span
>
</div> </div>
</div> </div>
</div> </div>
<!-- OAuth 账号 --> <!-- OAuth 账号 -->
<div v-if="filteredOAuthAccounts.length > 0"> <div v-if="filteredOAuthAccounts.length > 0">
<div class="bg-gray-50 px-4 py-2 text-xs font-semibold text-gray-500"> <div
class="bg-gray-50 px-4 py-2 text-xs font-semibold text-gray-500 dark:bg-gray-700 dark:text-gray-400"
>
{{ platform === 'claude' ? 'Claude OAuth 专属账号' : 'OAuth 专属账号' }} {{ platform === 'claude' ? 'Claude OAuth 专属账号' : 'OAuth 专属账号' }}
</div> </div>
<div <div
v-for="account in filteredOAuthAccounts" v-for="account in filteredOAuthAccounts"
:key="account.id" :key="account.id"
class="cursor-pointer px-4 py-2 transition-colors hover:bg-gray-50" class="cursor-pointer px-4 py-2 transition-colors hover:bg-gray-50 dark:hover:bg-gray-700"
:class="{ 'bg-blue-50': modelValue === account.id }" :class="{ 'bg-blue-50 dark:bg-blue-900/20': modelValue === account.id }"
@click="selectAccount(account.id)" @click="selectAccount(account.id)"
> >
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<span class="text-gray-700">{{ account.name }}</span> <span class="text-gray-700 dark:text-gray-300">{{ account.name }}</span>
<span <span
class="ml-2 rounded-full px-2 py-0.5 text-xs" class="ml-2 rounded-full px-2 py-0.5 text-xs"
:class=" :class="
account.isActive account.isActive
? 'bg-green-100 text-green-700' ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: account.status === 'unauthorized' : account.status === 'unauthorized'
? 'bg-orange-100 text-orange-700' ? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
: 'bg-red-100 text-red-700' : 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
" "
> >
{{ getAccountStatusText(account) }} {{ getAccountStatusText(account) }}
</span> </span>
</div> </div>
<span class="text-xs text-gray-400"> <span class="text-xs text-gray-400 dark:text-gray-500">
{{ formatDate(account.createdAt) }} {{ formatDate(account.createdAt) }}
</span> </span>
</div> </div>
@@ -120,33 +133,37 @@
<!-- Console 账号 Claude --> <!-- Console 账号 Claude -->
<div v-if="platform === 'claude' && filteredConsoleAccounts.length > 0"> <div v-if="platform === 'claude' && filteredConsoleAccounts.length > 0">
<div class="bg-gray-50 px-4 py-2 text-xs font-semibold text-gray-500"> <div
class="bg-gray-50 px-4 py-2 text-xs font-semibold text-gray-500 dark:bg-gray-700 dark:text-gray-400"
>
Claude Console 专属账号 Claude Console 专属账号
</div> </div>
<div <div
v-for="account in filteredConsoleAccounts" v-for="account in filteredConsoleAccounts"
:key="account.id" :key="account.id"
class="cursor-pointer px-4 py-2 transition-colors hover:bg-gray-50" class="cursor-pointer px-4 py-2 transition-colors hover:bg-gray-50 dark:hover:bg-gray-700"
:class="{ 'bg-blue-50': modelValue === `console:${account.id}` }" :class="{
'bg-blue-50 dark:bg-blue-900/20': modelValue === `console:${account.id}`
}"
@click="selectAccount(`console:${account.id}`)" @click="selectAccount(`console:${account.id}`)"
> >
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<span class="text-gray-700">{{ account.name }}</span> <span class="text-gray-700 dark:text-gray-300">{{ account.name }}</span>
<span <span
class="ml-2 rounded-full px-2 py-0.5 text-xs" class="ml-2 rounded-full px-2 py-0.5 text-xs"
:class=" :class="
account.isActive account.isActive
? 'bg-green-100 text-green-700' ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: account.status === 'unauthorized' : account.status === 'unauthorized'
? 'bg-orange-100 text-orange-700' ? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
: 'bg-red-100 text-red-700' : 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
" "
> >
{{ getAccountStatusText(account) }} {{ getAccountStatusText(account) }}
</span> </span>
</div> </div>
<span class="text-xs text-gray-400"> <span class="text-xs text-gray-400 dark:text-gray-500">
{{ formatDate(account.createdAt) }} {{ formatDate(account.createdAt) }}
</span> </span>
</div> </div>
@@ -154,7 +171,10 @@
</div> </div>
<!-- 无搜索结果 --> <!-- 无搜索结果 -->
<div v-if="searchQuery && !hasResults" class="px-4 py-8 text-center text-gray-500"> <div
v-if="searchQuery && !hasResults"
class="px-4 py-8 text-center text-gray-500 dark:text-gray-400"
>
<i class="fas fa-search mb-2 text-2xl" /> <i class="fas fa-search mb-2 text-2xl" />
<p class="text-sm">没有找到匹配的账号</p> <p class="text-sm">没有找到匹配的账号</p>
</div> </div>

View File

@@ -14,10 +14,10 @@
<i class="fas fa-exclamation-triangle text-lg text-white" /> <i class="fas fa-exclamation-triangle text-lg text-white" />
</div> </div>
<div class="flex-1"> <div class="flex-1">
<h3 class="mb-2 text-lg font-semibold text-gray-900"> <h3 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">
{{ title }} {{ title }}
</h3> </h3>
<div class="whitespace-pre-line leading-relaxed text-gray-600"> <div class="whitespace-pre-line leading-relaxed text-gray-700 dark:text-gray-400">
{{ message }} {{ message }}
</div> </div>
</div> </div>
@@ -25,7 +25,7 @@
<div class="flex items-center justify-end gap-3"> <div class="flex items-center justify-end gap-3">
<button <button
class="btn bg-gray-100 px-6 py-3 text-gray-700 hover:bg-gray-200" class="btn bg-gray-100 px-6 py-3 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
:disabled="isProcessing" :disabled="isProcessing"
@click="handleCancel" @click="handleCancel"
> >
@@ -141,6 +141,10 @@ defineExpose({
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
} }
:global(.dark) .modal {
background: rgba(0, 0, 0, 0.7);
}
.modal-content { .modal-content {
background: white; background: white;
border-radius: 16px; border-radius: 16px;
@@ -150,6 +154,12 @@ defineExpose({
overflow-y: auto; overflow-y: auto;
} }
:global(.dark) .modal-content {
background: #1f2937;
border: 1px solid #374151;
box-shadow: 0 20px 64px rgba(0, 0, 0, 0.8);
}
.btn { .btn {
@apply inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-semibold transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2; @apply inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-semibold transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2;
} }
@@ -197,12 +207,24 @@ defineExpose({
border-radius: 3px; border-radius: 3px;
} }
:global(.dark) .modal-content::-webkit-scrollbar-track {
background: #374151;
}
.modal-content::-webkit-scrollbar-thumb { .modal-content::-webkit-scrollbar-thumb {
background: #cbd5e1; background: #cbd5e1;
border-radius: 3px; border-radius: 3px;
} }
:global(.dark) .modal-content::-webkit-scrollbar-thumb {
background: #4b5563;
}
.modal-content::-webkit-scrollbar-thumb:hover { .modal-content::-webkit-scrollbar-thumb:hover {
background: #94a3b8; background: #94a3b8;
} }
:global(.dark) .modal-content::-webkit-scrollbar-thumb:hover {
background: #6b7280;
}
</style> </style>

View File

@@ -1,7 +1,9 @@
<template> <template>
<Teleport to="body"> <Teleport to="body">
<div v-if="show" class="modal fixed inset-0 z-50 flex items-center justify-center p-4"> <div v-if="show" class="modal fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="modal-content mx-auto w-full max-w-md p-6"> <div
class="modal-content mx-auto w-full max-w-md rounded-2xl bg-white p-6 shadow-xl dark:bg-gray-800"
>
<div class="mb-6 flex items-start gap-4"> <div class="mb-6 flex items-start gap-4">
<div <div
class="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-yellow-400 to-yellow-500" class="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-yellow-400 to-yellow-500"
@@ -9,10 +11,10 @@
<i class="fas fa-exclamation text-xl text-white" /> <i class="fas fa-exclamation text-xl text-white" />
</div> </div>
<div class="flex-1"> <div class="flex-1">
<h3 class="mb-2 text-lg font-bold text-gray-900"> <h3 class="mb-2 text-lg font-bold text-gray-900 dark:text-white">
{{ title }} {{ title }}
</h3> </h3>
<p class="whitespace-pre-line text-sm leading-relaxed text-gray-600"> <p class="whitespace-pre-line text-sm leading-relaxed text-gray-700 dark:text-gray-300">
{{ message }} {{ message }}
</p> </p>
</div> </div>
@@ -20,7 +22,7 @@
<div class="flex gap-3"> <div class="flex gap-3">
<button <button
class="flex-1 rounded-xl bg-gray-100 px-4 py-2.5 font-medium text-gray-700 transition-colors hover:bg-gray-200" class="flex-1 rounded-xl bg-gray-100 px-4 py-2.5 font-medium text-gray-700 transition-colors hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
@click="$emit('cancel')" @click="$emit('cancel')"
> >
{{ cancelText }} {{ cancelText }}
@@ -63,3 +65,15 @@ defineProps({
defineEmits(['confirm', 'cancel']) defineEmits(['confirm', 'cancel'])
</script> </script>
<style scoped>
.modal {
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(8px);
}
:global(.dark) .modal {
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(8px);
}
</style>

View File

@@ -3,17 +3,19 @@
<!-- 触发器 --> <!-- 触发器 -->
<div <div
ref="triggerRef" ref="triggerRef"
class="relative flex cursor-pointer items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 shadow-sm transition-all duration-200 hover:shadow-md" class="relative flex cursor-pointer items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 shadow-sm transition-all duration-200 hover:shadow-md dark:border-gray-600 dark:bg-gray-800"
:class="[isOpen && 'border-blue-400 shadow-md']" :class="[isOpen && 'border-blue-400 shadow-md']"
@click="toggleDropdown" @click="toggleDropdown"
> >
<i v-if="icon" :class="['fas', icon, 'text-sm', iconColor]"></i> <i v-if="icon" :class="['fas', icon, 'text-sm', iconColor]"></i>
<span class="select-none whitespace-nowrap text-sm font-medium text-gray-700"> <span
class="select-none whitespace-nowrap text-sm font-medium text-gray-700 dark:text-gray-200"
>
{{ selectedLabel || placeholder }} {{ selectedLabel || placeholder }}
</span> </span>
<i <i
:class="[ :class="[
'fas fa-chevron-down ml-auto text-xs text-gray-400 transition-transform duration-200', 'fas fa-chevron-down ml-auto text-xs text-gray-400 transition-transform duration-200 dark:text-gray-500',
isOpen && 'rotate-180' isOpen && 'rotate-180'
]" ]"
></i> ></i>
@@ -32,7 +34,7 @@
<div <div
v-if="isOpen" v-if="isOpen"
ref="dropdownRef" ref="dropdownRef"
class="fixed z-[9999] min-w-max overflow-hidden rounded-lg border border-gray-200 bg-white shadow-lg" class="fixed z-[9999] min-w-max overflow-hidden rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-600 dark:bg-gray-800"
:style="dropdownStyle" :style="dropdownStyle"
> >
<div class="max-h-60 overflow-y-auto py-1"> <div class="max-h-60 overflow-y-auto py-1">
@@ -42,8 +44,8 @@
class="flex cursor-pointer items-center gap-2 whitespace-nowrap px-3 py-2 text-sm transition-colors duration-150" class="flex cursor-pointer items-center gap-2 whitespace-nowrap px-3 py-2 text-sm transition-colors duration-150"
:class="[ :class="[
option.value === modelValue option.value === modelValue
? 'bg-blue-50 font-medium text-blue-700' ? 'bg-blue-50 font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
: 'text-gray-700 hover:bg-gray-50' : 'text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-700'
]" ]"
@click="selectOption(option)" @click="selectOption(option)"
> >

View File

@@ -2,7 +2,7 @@
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<!-- Logo区域 --> <!-- Logo区域 -->
<div <div
class="flex h-12 w-12 flex-shrink-0 items-center justify-center overflow-hidden rounded-xl border border-gray-300/30 bg-gradient-to-br from-blue-500/20 to-purple-500/20 backdrop-blur-sm" class="flex h-12 w-12 flex-shrink-0 items-center justify-center overflow-hidden rounded-xl border border-gray-300/30 bg-gradient-to-br from-blue-500/20 to-purple-500/20 backdrop-blur-sm dark:border-gray-600/30 dark:from-blue-600/20 dark:to-purple-600/20"
> >
<template v-if="!loading"> <template v-if="!loading">
<img <img
@@ -12,9 +12,9 @@
:src="logoSrc" :src="logoSrc"
@error="handleLogoError" @error="handleLogoError"
/> />
<i v-else class="fas fa-cloud text-xl text-gray-700" /> <i v-else class="fas fa-cloud text-xl text-gray-700 dark:text-gray-300" />
</template> </template>
<div v-else class="h-8 w-8 animate-pulse rounded bg-gray-300/50" /> <div v-else class="h-8 w-8 animate-pulse rounded bg-gray-300/50 dark:bg-gray-600/50" />
</div> </div>
<!-- 标题区域 --> <!-- 标题区域 -->
@@ -25,11 +25,14 @@
{{ title }} {{ title }}
</h1> </h1>
</template> </template>
<div v-else-if="loading" class="h-8 w-64 animate-pulse rounded bg-gray-300/50" /> <div
v-else-if="loading"
class="h-8 w-64 animate-pulse rounded bg-gray-300/50 dark:bg-gray-600/50"
/>
<!-- 插槽用于版本信息等额外内容 --> <!-- 插槽用于版本信息等额外内容 -->
<slot name="after-title" /> <slot name="after-title" />
</div> </div>
<p v-if="subtitle" class="mt-0.5 text-sm leading-tight text-gray-600"> <p v-if="subtitle" class="mt-0.5 text-sm leading-tight text-gray-600 dark:text-gray-400">
{{ subtitle }} {{ subtitle }}
</p> </p>
</div> </div>

View File

@@ -2,13 +2,16 @@
<div class="stat-card"> <div class="stat-card">
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div class="flex-1"> <div class="flex-1">
<p class="mb-1 text-xs font-medium text-gray-600 sm:text-sm"> <p class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-400 sm:text-sm">
{{ title }} {{ title }}
</p> </p>
<p class="text-2xl font-bold text-gray-800 sm:text-3xl"> <p class="text-2xl font-bold text-gray-800 dark:text-gray-100 sm:text-3xl">
{{ value }} {{ value }}
</p> </p>
<p v-if="subtitle" class="mt-1.5 text-xs text-gray-500 sm:mt-2 sm:text-sm"> <p
v-if="subtitle"
class="mt-1.5 text-xs text-gray-500 dark:text-gray-400 sm:mt-2 sm:text-sm"
>
{{ subtitle }} {{ subtitle }}
</p> </p>
</div> </div>

View File

@@ -0,0 +1,573 @@
<template>
<div class="theme-toggle-container">
<!-- 紧凑模式仅显示图标按钮 -->
<button
v-if="mode === 'compact'"
class="theme-toggle-button"
:title="themeTooltip"
@click="handleCycleTheme"
>
<transition mode="out-in" name="fade">
<i v-if="themeStore.themeMode === 'light'" key="sun" class="fas fa-sun" />
<i v-else-if="themeStore.themeMode === 'dark'" key="moon" class="fas fa-moon" />
<i v-else key="auto" class="fas fa-circle-half-stroke" />
</transition>
</button>
<!-- 下拉菜单模式 - 改为创意切换开关 -->
<div v-else-if="mode === 'dropdown'" class="theme-switch-wrapper">
<button
class="theme-switch"
:class="{
'is-dark': themeStore.themeMode === 'dark',
'is-auto': themeStore.themeMode === 'auto'
}"
:title="themeTooltip"
@click="handleCycleTheme"
>
<!-- 背景装饰 -->
<div class="switch-bg">
<div class="stars">
<span></span>
<span></span>
<span></span>
</div>
<div class="clouds">
<span></span>
<span></span>
</div>
</div>
<!-- 切换滑块 -->
<div class="switch-handle">
<div class="handle-icon">
<i v-if="themeStore.themeMode === 'light'" class="fas fa-sun" />
<i v-else-if="themeStore.themeMode === 'dark'" class="fas fa-moon" />
<i v-else class="fas fa-circle-half-stroke" />
</div>
</div>
</button>
</div>
<!-- 分段按钮模式 -->
<div v-else-if="mode === 'segmented'" class="theme-segmented">
<button
v-for="option in themeOptions"
:key="option.value"
class="theme-segment"
:class="{ active: themeStore.themeMode === option.value }"
:title="option.label"
@click="selectTheme(option.value)"
>
<i :class="option.icon" />
<span v-if="showLabel" class="ml-1 hidden sm:inline">{{ option.shortLabel }}</span>
</button>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useThemeStore } from '@/stores/theme'
// Props
defineProps({
// 显示模式compact紧凑、dropdown下拉、segmented分段
mode: {
type: String,
default: 'compact',
validator: (value) => ['compact', 'dropdown', 'segmented'].includes(value)
},
// 是否显示文字标签
showLabel: {
type: Boolean,
default: false
}
})
// Store
const themeStore = useThemeStore()
// 主题选项配置
const themeOptions = [
{
value: 'light',
label: '浅色模式',
shortLabel: '浅色',
icon: 'fas fa-sun'
},
{
value: 'dark',
label: '深色模式',
shortLabel: '深色',
icon: 'fas fa-moon'
},
{
value: 'auto',
label: '跟随系统',
shortLabel: '自动',
icon: 'fas fa-circle-half-stroke'
}
]
// 计算属性
const themeTooltip = computed(() => {
const current = themeOptions.find((opt) => opt.value === themeStore.themeMode)
return current ? `点击切换主题 - ${current.label}` : '切换主题'
})
// 方法
const handleCycleTheme = () => {
themeStore.cycleThemeMode()
}
const selectTheme = (mode) => {
themeStore.setThemeMode(mode)
}
</script>
<style scoped>
/* 容器样式 */
.theme-toggle-container {
position: relative;
display: inline-flex;
align-items: center;
}
/* 基础按钮样式 - 更简洁优雅 */
.theme-toggle-button {
@apply flex items-center justify-center;
@apply h-9 w-9 rounded-full;
@apply bg-white/80 dark:bg-gray-800/80;
@apply hover:bg-white/90 dark:hover:bg-gray-700/90;
@apply text-gray-600 dark:text-gray-300;
@apply border border-gray-200/50 dark:border-gray-600/50;
@apply transition-all duration-200 ease-out;
@apply shadow-md backdrop-blur-sm hover:shadow-lg;
@apply hover:scale-110 active:scale-95;
position: relative;
overflow: hidden;
}
/* 添加优雅的光环效果 */
.theme-toggle-button::before {
content: '';
position: absolute;
inset: -2px;
border-radius: inherit;
background: conic-gradient(
from 180deg at 50% 50%,
rgba(59, 130, 246, 0.2),
rgba(147, 51, 234, 0.2),
rgba(59, 130, 246, 0.2)
);
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
animation: rotate 3s linear infinite;
}
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.theme-toggle-button:hover::before {
opacity: 0.6;
}
/* 图标样式优化 - 更生动 */
.theme-toggle-button i {
@apply text-base;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.1));
transition: all 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55);
}
.theme-toggle-button:hover i {
transform: rotate(180deg) scale(1.1);
}
/* 不同主题的图标颜色 */
.theme-toggle-button i.fa-sun {
@apply text-amber-500;
}
.theme-toggle-button i.fa-moon {
@apply text-indigo-500;
}
.theme-toggle-button i.fa-circle-half-stroke {
background: linear-gradient(90deg, #60a5fa 0%, #2563eb 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
/* 创意切换开关样式 */
.theme-switch-wrapper {
@apply inline-flex items-center;
}
.theme-switch {
@apply relative;
width: 76px;
height: 38px;
border-radius: 50px;
padding: 4px;
cursor: pointer;
transition: all 0.4s cubic-bezier(0.68, -0.55, 0.265, 1.55);
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: 2px solid rgba(255, 255, 255, 0.1);
box-shadow:
0 4px 15px rgba(102, 126, 234, 0.3),
inset 0 1px 2px rgba(0, 0, 0, 0.1);
overflow: hidden;
display: flex;
align-items: center;
}
.theme-switch:hover {
transform: scale(1.05);
box-shadow:
0 6px 20px rgba(102, 126, 234, 0.4),
inset 0 1px 2px rgba(0, 0, 0, 0.1);
}
.theme-switch:active {
transform: scale(0.98);
}
/* 深色模式样式 */
.theme-switch.is-dark {
background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
border-color: rgba(148, 163, 184, 0.2);
box-shadow:
0 4px 15px rgba(0, 0, 0, 0.5),
inset 0 1px 2px rgba(255, 255, 255, 0.05);
}
.theme-switch.is-dark:hover {
box-shadow:
0 6px 20px rgba(0, 0, 0, 0.6),
inset 0 1px 2px rgba(255, 255, 255, 0.05);
}
/* 自动模式样式 - 静态蓝紫渐变设计(优化版) */
.theme-switch.is-auto {
background: linear-gradient(
135deg,
#c4b5fd 0%,
/* 更柔和的起始:淡紫 */ #a78bfa 15%,
/* 浅紫 */ #818cf8 40%,
/* 紫蓝 */ #6366f1 60%,
/* 靛蓝 */ #4f46e5 85%,
/* 深蓝紫 */ #4338ca 100% /* 更深的结束:深紫 */
);
border-color: rgba(255, 255, 255, 0.2);
position: relative;
overflow: hidden;
background-size: 120% 120%;
background-position: center;
box-shadow:
0 4px 15px rgba(139, 92, 246, 0.25),
inset 0 1px 3px rgba(0, 0, 0, 0.1),
inset 0 -1px 3px rgba(0, 0, 0, 0.1);
}
/* 自动模式的分割线效果 */
.theme-switch.is-auto::before {
content: '';
position: absolute;
left: 50%;
top: 10%;
bottom: 10%;
width: 1px;
background: linear-gradient(to bottom, transparent, rgba(255, 255, 255, 0.3), transparent);
transform: translateX(-50%);
pointer-events: none;
}
/* 背景装饰 */
.switch-bg {
position: absolute;
inset: 0;
border-radius: inherit;
overflow: hidden;
pointer-events: none;
}
/* 星星装饰(深色模式) */
.stars {
position: absolute;
width: 100%;
height: 100%;
opacity: 0;
transition: opacity 0.4s ease;
}
.theme-switch.is-dark .stars {
opacity: 1;
}
.stars span {
position: absolute;
display: block;
width: 2px;
height: 2px;
background: white;
border-radius: 50%;
box-shadow: 0 0 2px white;
animation: twinkle 3s infinite;
}
.stars span:nth-child(1) {
top: 25%;
left: 20%;
animation-delay: 0s;
}
.stars span:nth-child(2) {
top: 40%;
left: 40%;
animation-delay: 1s;
}
.stars span:nth-child(3) {
top: 60%;
left: 25%;
animation-delay: 2s;
}
@keyframes twinkle {
0%,
100% {
opacity: 0;
transform: scale(0.5);
}
50% {
opacity: 1;
transform: scale(1);
}
}
/* 云朵装饰(浅色模式) */
.clouds {
position: absolute;
width: 100%;
height: 100%;
opacity: 0;
transition: opacity 0.4s ease;
}
.theme-switch:not(.is-dark):not(.is-auto) .clouds {
opacity: 1;
}
.clouds span {
position: absolute;
background: rgba(255, 255, 255, 0.4);
border-radius: 100px;
}
.clouds span:nth-child(1) {
width: 20px;
height: 8px;
top: 40%;
left: 15%;
animation: float 4s infinite ease-in-out;
}
.clouds span:nth-child(2) {
width: 15px;
height: 6px;
top: 60%;
left: 35%;
animation: float 4s infinite ease-in-out;
animation-delay: 1s;
}
@keyframes float {
0%,
100% {
transform: translateX(0);
}
50% {
transform: translateX(5px);
}
}
/* 切换滑块 */
.switch-handle {
position: absolute;
width: 30px;
height: 30px;
background: white;
border-radius: 50%;
transition: all 0.4s cubic-bezier(0.68, -0.55, 0.265, 1.55);
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.2),
0 0 0 1px rgba(0, 0, 0, 0.05);
display: flex;
align-items: center;
justify-content: center;
top: 50%;
transform: translateY(-50%) translateX(0);
left: 4px;
}
/* 深色模式滑块位置 */
.theme-switch.is-dark .switch-handle {
transform: translateY(-50%) translateX(38px);
background: linear-gradient(135deg, #1e293b 0%, #475569 100%);
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.4),
0 0 0 1px rgba(255, 255, 255, 0.1);
}
/* 自动模式滑块位置 - 玻璃态设计 */
.theme-switch.is-auto .switch-handle {
transform: translateY(-50%) translateX(19px);
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.1),
inset 0 0 8px rgba(255, 255, 255, 0.2);
}
/* 滑块图标 */
.handle-icon {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.handle-icon i {
font-size: 14px;
transition: all 0.3s ease;
}
.handle-icon .fa-sun {
color: #f59e0b;
filter: drop-shadow(0 0 3px rgba(245, 158, 11, 0.5));
}
.handle-icon .fa-moon {
color: #fbbf24;
filter: drop-shadow(0 0 3px rgba(251, 191, 36, 0.5));
}
.handle-icon .fa-circle-half-stroke {
color: rgba(255, 255, 255, 0.9);
filter: drop-shadow(0 0 4px rgba(167, 139, 250, 0.5));
font-size: 15px;
}
/* 滑块悬停动画 */
.theme-switch:hover .switch-handle {
animation: bounce 0.5s ease;
}
@keyframes bounce {
0%,
100% {
transform: translateY(-50%) translateX(var(--handle-x, 0));
}
50% {
transform: translateY(calc(-50% - 3px)) translateX(var(--handle-x, 0));
}
}
.theme-switch.is-dark:hover .switch-handle {
--handle-x: 38px;
}
.theme-switch.is-auto:hover .switch-handle {
--handle-x: 19px;
}
/* 分段按钮样式 - 更现代 */
.theme-segmented {
@apply inline-flex;
@apply bg-gray-100 dark:bg-gray-800;
@apply rounded-full p-1;
@apply border border-gray-200 dark:border-gray-700;
@apply shadow-sm;
}
.theme-segment {
@apply px-3 py-1.5;
@apply text-xs font-medium;
@apply text-gray-500 dark:text-gray-400;
@apply transition-all duration-200;
@apply rounded-full;
@apply flex items-center gap-1;
@apply cursor-pointer;
position: relative;
}
.theme-segment:hover {
@apply text-gray-700 dark:text-gray-300;
@apply bg-white/30 dark:bg-gray-600/30;
transform: scale(1.02);
}
.theme-segment.active {
@apply bg-white dark:bg-gray-700;
@apply text-gray-900 dark:text-white;
@apply shadow-sm;
}
.theme-segment i {
@apply text-xs;
transition: transform 0.2s ease;
}
/* 过渡动画 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.dropdown-enter-active {
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.dropdown-leave-active {
transition: all 0.2s cubic-bezier(0.4, 0, 1, 1);
}
.dropdown-enter-from {
opacity: 0;
transform: translateY(-10px) scale(0.95);
}
.dropdown-leave-to {
opacity: 0;
transform: translateY(-5px) scale(0.98);
}
/* 响应式调整 */
@media (max-width: 640px) {
.theme-dropdown {
@apply left-0 right-auto;
}
.theme-segment span {
@apply hidden;
}
}
</style>

View File

@@ -162,6 +162,12 @@ defineExpose({
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
} }
:global(.dark) .toast {
background: #1f2937;
border: 1px solid #374151;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.toast-show { .toast-show {
transform: translateX(0); transform: translateX(0);
opacity: 1; opacity: 1;
@@ -227,6 +233,11 @@ defineExpose({
color: #6b7280; color: #6b7280;
} }
:global(.dark) .toast-close:hover {
background: #374151;
color: #9ca3af;
}
.toast-progress { .toast-progress {
position: absolute; position: absolute;
bottom: 0; bottom: 0;
@@ -256,14 +267,26 @@ defineExpose({
background: #d1fae5; background: #d1fae5;
} }
:global(.dark) .toast-success .toast-icon {
background: #064e3b;
}
.toast-success .toast-title { .toast-success .toast-title {
color: #065f46; color: #065f46;
} }
:global(.dark) .toast-success .toast-title {
color: #10b981;
}
.toast-success .toast-message { .toast-success .toast-message {
color: #047857; color: #047857;
} }
:global(.dark) .toast-success .toast-message {
color: #34d399;
}
.toast-success .toast-progress { .toast-success .toast-progress {
background: #10b981; background: #10b981;
} }
@@ -278,14 +301,26 @@ defineExpose({
background: #fee2e2; background: #fee2e2;
} }
:global(.dark) .toast-error .toast-icon {
background: #7f1d1d;
}
.toast-error .toast-title { .toast-error .toast-title {
color: #991b1b; color: #991b1b;
} }
:global(.dark) .toast-error .toast-title {
color: #ef4444;
}
.toast-error .toast-message { .toast-error .toast-message {
color: #dc2626; color: #dc2626;
} }
:global(.dark) .toast-error .toast-message {
color: #f87171;
}
.toast-error .toast-progress { .toast-error .toast-progress {
background: #ef4444; background: #ef4444;
} }
@@ -300,14 +335,26 @@ defineExpose({
background: #fef3c7; background: #fef3c7;
} }
:global(.dark) .toast-warning .toast-icon {
background: #78350f;
}
.toast-warning .toast-title { .toast-warning .toast-title {
color: #92400e; color: #92400e;
} }
:global(.dark) .toast-warning .toast-title {
color: #f59e0b;
}
.toast-warning .toast-message { .toast-warning .toast-message {
color: #d97706; color: #d97706;
} }
:global(.dark) .toast-warning .toast-message {
color: #fbbf24;
}
.toast-warning .toast-progress { .toast-warning .toast-progress {
background: #f59e0b; background: #f59e0b;
} }
@@ -322,14 +369,26 @@ defineExpose({
background: #dbeafe; background: #dbeafe;
} }
:global(.dark) .toast-info .toast-icon {
background: #1e3a8a;
}
.toast-info .toast-title { .toast-info .toast-title {
color: #1e40af; color: #1e40af;
} }
:global(.dark) .toast-info .toast-title {
color: #3b82f6;
}
.toast-info .toast-message { .toast-info .toast-message {
color: #2563eb; color: #2563eb;
} }
:global(.dark) .toast-info .toast-message {
color: #60a5fa;
}
.toast-info .toast-progress { .toast-info .toast-progress {
background: #3b82f6; background: #3b82f6;
} }

View File

@@ -13,12 +13,12 @@
:logo-src="oemSettings.siteIconData || oemSettings.siteIcon" :logo-src="oemSettings.siteIconData || oemSettings.siteIcon"
subtitle="管理后台" subtitle="管理后台"
:title="oemSettings.siteName" :title="oemSettings.siteName"
title-class="text-white" title-class="text-white dark:text-gray-100"
> >
<template #after-title> <template #after-title>
<!-- 版本信息 --> <!-- 版本信息 -->
<div class="flex items-center gap-1 sm:gap-2"> <div class="flex items-center gap-1 sm:gap-2">
<span class="font-mono text-xs text-gray-400 sm:text-sm" <span class="font-mono text-xs text-gray-400 dark:text-gray-500 sm:text-sm"
>v{{ versionInfo.current || '...' }}</span >v{{ versionInfo.current || '...' }}</span
> >
<!-- 更新提示 --> <!-- 更新提示 -->
@@ -36,16 +36,28 @@
</template> </template>
</LogoTitle> </LogoTitle>
</div> </div>
<!-- 主题切换和用户菜单 -->
<div class="flex items-center gap-2 sm:gap-4">
<!-- 主题切换按钮 -->
<div class="flex items-center">
<ThemeToggle mode="dropdown" />
</div>
<!-- 分隔线 -->
<div
class="h-8 w-px bg-gradient-to-b from-transparent via-gray-300 to-transparent opacity-50 dark:via-gray-600"
/>
<!-- 用户菜单 --> <!-- 用户菜单 -->
<div class="user-menu-container relative"> <div class="user-menu-container relative">
<button <button
class="btn btn-primary relative flex items-center gap-1 px-3 py-2 text-sm sm:gap-2 sm:px-4 sm:py-3 sm:text-base" class="user-menu-button flex items-center gap-2 rounded-2xl bg-gradient-to-r from-blue-500 to-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-lg transition-all duration-200 hover:scale-105 hover:shadow-xl active:scale-95 sm:px-4 sm:py-2.5"
@click="userMenuOpen = !userMenuOpen" @click="userMenuOpen = !userMenuOpen"
> >
<i class="fas fa-user-circle" /> <i class="fas fa-user-circle text-sm sm:text-base" />
<span class="hidden sm:inline">{{ currentUser.username || 'Admin' }}</span> <span class="hidden sm:inline">{{ currentUser.username || 'Admin' }}</span>
<i <i
class="fas fa-chevron-down text-xs transition-transform duration-200" class="fas fa-chevron-down ml-1 text-xs transition-transform duration-200"
:class="{ 'rotate-180': userMenuOpen }" :class="{ 'rotate-180': userMenuOpen }"
/> />
</button> </button>
@@ -53,22 +65,26 @@
<!-- 悬浮菜单 --> <!-- 悬浮菜单 -->
<div <div
v-if="userMenuOpen" v-if="userMenuOpen"
class="user-menu-dropdown absolute right-0 top-full mt-2 w-48 rounded-xl border border-gray-200 bg-white py-2 shadow-xl sm:w-56" class="user-menu-dropdown absolute right-0 top-full mt-2 w-48 rounded-xl border border-gray-200 bg-white py-2 shadow-xl dark:border-gray-700 dark:bg-gray-800 sm:w-56"
style="z-index: 999999" style="z-index: 999999"
@click.stop @click.stop
> >
<!-- 版本信息 --> <!-- 版本信息 -->
<div class="border-b border-gray-100 px-4 py-3"> <div class="border-b border-gray-100 px-4 py-3 dark:border-gray-700">
<div class="flex items-center justify-between text-sm"> <div class="flex items-center justify-between text-sm">
<span class="text-gray-500">当前版本</span> <span class="text-gray-500 dark:text-gray-400">当前版本</span>
<span class="font-mono text-gray-700">v{{ versionInfo.current || '...' }}</span> <span class="font-mono text-gray-700 dark:text-gray-300"
>v{{ versionInfo.current || '...' }}</span
>
</div> </div>
<div v-if="versionInfo.hasUpdate" class="mt-2"> <div v-if="versionInfo.hasUpdate" class="mt-2">
<div class="mb-2 flex items-center justify-between text-sm"> <div class="mb-2 flex items-center justify-between text-sm">
<span class="font-medium text-green-600"> <span class="font-medium text-green-600 dark:text-green-400">
<i class="fas fa-arrow-up mr-1" />有新版本 <i class="fas fa-arrow-up mr-1" />有新版本
</span> </span>
<span class="font-mono text-green-600">v{{ versionInfo.latest }}</span> <span class="font-mono text-green-600 dark:text-green-400"
>v{{ versionInfo.latest }}</span
>
</div> </div>
<a <a
class="block w-full rounded-lg bg-green-500 px-3 py-1.5 text-center text-sm text-white transition-colors hover:bg-green-600" class="block w-full rounded-lg bg-green-500 px-3 py-1.5 text-center text-sm text-white transition-colors hover:bg-green-600"
@@ -80,7 +96,7 @@
</div> </div>
<div <div
v-else-if="versionInfo.checkingUpdate" v-else-if="versionInfo.checkingUpdate"
class="mt-2 text-center text-xs text-gray-500" class="mt-2 text-center text-xs text-gray-500 dark:text-gray-400"
> >
<i class="fas fa-spinner fa-spin mr-1" />检查更新中... <i class="fas fa-spinner fa-spin mr-1" />检查更新中...
</div> </div>
@@ -90,16 +106,16 @@
<div <div
v-if="versionInfo.noUpdateMessage" v-if="versionInfo.noUpdateMessage"
key="message" key="message"
class="inline-block rounded-lg border border-green-200 bg-green-100 px-3 py-1.5" class="inline-block rounded-lg border border-green-200 bg-green-100 px-3 py-1.5 dark:border-green-800 dark:bg-green-900/30"
> >
<p class="text-xs font-medium text-green-700"> <p class="text-xs font-medium text-green-700 dark:text-green-400">
<i class="fas fa-check-circle mr-1" />当前已是最新版本 <i class="fas fa-check-circle mr-1" />当前已是最新版本
</p> </p>
</div> </div>
<button <button
v-else v-else
key="button" key="button"
class="text-xs text-blue-500 transition-colors hover:text-blue-700" class="text-xs text-blue-500 transition-colors hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
@click="checkForUpdates()" @click="checkForUpdates()"
> >
<i class="fas fa-sync-alt mr-1" />检查更新 <i class="fas fa-sync-alt mr-1" />检查更新
@@ -109,17 +125,17 @@
</div> </div>
<button <button
class="flex w-full items-center gap-3 px-4 py-3 text-left text-gray-700 transition-colors hover:bg-gray-50" class="flex w-full items-center gap-3 px-4 py-3 text-left text-gray-700 transition-colors hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-700"
@click="openChangePasswordModal" @click="openChangePasswordModal"
> >
<i class="fas fa-key text-blue-500" /> <i class="fas fa-key text-blue-500" />
<span>修改账户信息</span> <span>修改账户信息</span>
</button> </button>
<hr class="my-2 border-gray-200" /> <hr class="my-2 border-gray-200 dark:border-gray-700" />
<button <button
class="flex w-full items-center gap-3 px-4 py-3 text-left text-gray-700 transition-colors hover:bg-gray-50" class="flex w-full items-center gap-3 px-4 py-3 text-left text-gray-700 transition-colors hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-700"
@click="logout" @click="logout"
> >
<i class="fas fa-sign-out-alt text-red-500" /> <i class="fas fa-sign-out-alt text-red-500" />
@@ -129,6 +145,7 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- 修改账户信息模态框 --> <!-- 修改账户信息模态框 -->
<div <div
@@ -143,10 +160,10 @@
> >
<i class="fas fa-key text-white" /> <i class="fas fa-key text-white" />
</div> </div>
<h3 class="text-xl font-bold text-gray-900">修改账户信息</h3> <h3 class="text-xl font-bold text-gray-900 dark:text-gray-100">修改账户信息</h3>
</div> </div>
<button <button
class="text-gray-400 transition-colors hover:text-gray-600" class="text-gray-400 transition-colors hover:text-gray-600 dark:hover:text-gray-300"
@click="closeChangePasswordModal" @click="closeChangePasswordModal"
> >
<i class="fas fa-times text-xl" /> <i class="fas fa-times text-xl" />
@@ -158,29 +175,37 @@
@submit.prevent="changePassword" @submit.prevent="changePassword"
> >
<div> <div>
<label class="mb-3 block text-sm font-semibold text-gray-700">当前用户名</label> <label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>当前用户名</label
>
<input <input
class="form-input w-full cursor-not-allowed bg-gray-100" class="form-input w-full cursor-not-allowed bg-gray-100 dark:bg-gray-700 dark:text-gray-300"
disabled disabled
type="text" type="text"
:value="currentUser.username || 'Admin'" :value="currentUser.username || 'Admin'"
/> />
<p class="mt-2 text-xs text-gray-500">当前用户名输入新用户名以修改</p> <p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
当前用户名输入新用户名以修改
</p>
</div> </div>
<div> <div>
<label class="mb-3 block text-sm font-semibold text-gray-700">新用户名</label> <label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>新用户名</label
>
<input <input
v-model="changePasswordForm.newUsername" v-model="changePasswordForm.newUsername"
class="form-input w-full" class="form-input w-full"
placeholder="输入新用户名(留空保持不变)" placeholder="输入新用户名(留空保持不变)"
type="text" type="text"
/> />
<p class="mt-2 text-xs text-gray-500">留空表示不修改用户名</p> <p class="mt-2 text-xs text-gray-500 dark:text-gray-400">留空表示不修改用户名</p>
</div> </div>
<div> <div>
<label class="mb-3 block text-sm font-semibold text-gray-700">当前密码</label> <label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>当前密码</label
>
<input <input
v-model="changePasswordForm.currentPassword" v-model="changePasswordForm.currentPassword"
class="form-input w-full" class="form-input w-full"
@@ -191,7 +216,9 @@
</div> </div>
<div> <div>
<label class="mb-3 block text-sm font-semibold text-gray-700">新密码</label> <label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>新密码</label
>
<input <input
v-model="changePasswordForm.newPassword" v-model="changePasswordForm.newPassword"
class="form-input w-full" class="form-input w-full"
@@ -199,11 +226,13 @@
required required
type="password" type="password"
/> />
<p class="mt-2 text-xs text-gray-500">密码长度至少8位</p> <p class="mt-2 text-xs text-gray-500 dark:text-gray-400">密码长度至少8位</p>
</div> </div>
<div> <div>
<label class="mb-3 block text-sm font-semibold text-gray-700">确认新密码</label> <label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>确认新密码</label
>
<input <input
v-model="changePasswordForm.confirmPassword" v-model="changePasswordForm.confirmPassword"
class="form-input w-full" class="form-input w-full"
@@ -215,7 +244,7 @@
<div class="flex gap-3 pt-4"> <div class="flex gap-3 pt-4">
<button <button
class="flex-1 rounded-xl bg-gray-100 px-6 py-3 font-semibold text-gray-700 transition-colors hover:bg-gray-200" class="flex-1 rounded-xl bg-gray-100 px-6 py-3 font-semibold text-gray-700 transition-colors hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button" type="button"
@click="closeChangePasswordModal" @click="closeChangePasswordModal"
> >
@@ -243,6 +272,7 @@ import { useAuthStore } from '@/stores/auth'
import { showToast } from '@/utils/toast' import { showToast } from '@/utils/toast'
import { apiClient } from '@/config/api' import { apiClient } from '@/config/api'
import LogoTitle from '@/components/common/LogoTitle.vue' import LogoTitle from '@/components/common/LogoTitle.vue'
import ThemeToggle from '@/components/common/ThemeToggle.vue'
const router = useRouter() const router = useRouter()
const authStore = useAuthStore() const authStore = useAuthStore()
@@ -430,9 +460,44 @@ onUnmounted(() => {
</script> </script>
<style scoped> <style scoped>
/* 用户菜单按钮样式 */
.user-menu-button {
position: relative;
overflow: hidden;
min-height: 38px;
}
/* 添加光泽效果 */
.user-menu-button::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
transition: left 0.5s;
}
.user-menu-button:hover::before {
left: 100%;
}
/* 用户菜单样式优化 */ /* 用户菜单样式优化 */
.user-menu-dropdown { .user-menu-dropdown {
margin-top: 8px; margin-top: 8px;
animation: slideDown 0.3s ease-out;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
} }
/* fade过渡动画 */ /* fade过渡动画 */

View File

@@ -13,20 +13,14 @@
<!-- 内容区域 --> <!-- 内容区域 -->
<div class="tab-content"> <div class="tab-content">
<router-view v-slot="{ Component }"> <router-view />
<transition mode="out-in" name="slide-up">
<keep-alive :include="['DashboardView', 'ApiKeysView']">
<component :is="Component" />
</keep-alive>
</transition>
</router-view>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, watch } from 'vue' import { ref, watch, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import AppHeader from './AppHeader.vue' import AppHeader from './AppHeader.vue'
import TabBar from './TabBar.vue' import TabBar from './TabBar.vue'
@@ -46,6 +40,35 @@ const tabRouteMap = {
settings: '/settings' settings: '/settings'
} }
// 初始化当前激活的标签
const initActiveTab = () => {
const currentPath = route.path
const tabKey = Object.keys(tabRouteMap).find((key) => tabRouteMap[key] === currentPath)
if (tabKey) {
activeTab.value = tabKey
} else {
// 如果路径不匹配任何标签,尝试从路由名称获取
const routeName = route.name
const nameToTabMap = {
Dashboard: 'dashboard',
ApiKeys: 'apiKeys',
Accounts: 'accounts',
Tutorial: 'tutorial',
Settings: 'settings'
}
if (routeName && nameToTabMap[routeName]) {
activeTab.value = nameToTabMap[routeName]
} else {
// 默认选中仪表板
activeTab.value = 'dashboard'
}
}
}
// 初始化
initActiveTab()
// 监听路由变化,更新激活的标签 // 监听路由变化,更新激活的标签
watch( watch(
() => route.path, () => route.path,
@@ -53,15 +76,46 @@ watch(
const tabKey = Object.keys(tabRouteMap).find((key) => tabRouteMap[key] === newPath) const tabKey = Object.keys(tabRouteMap).find((key) => tabRouteMap[key] === newPath)
if (tabKey) { if (tabKey) {
activeTab.value = tabKey activeTab.value = tabKey
} else {
// 如果路径不匹配任何标签,尝试从路由名称获取
const routeName = route.name
const nameToTabMap = {
Dashboard: 'dashboard',
ApiKeys: 'apiKeys',
Accounts: 'accounts',
Tutorial: 'tutorial',
Settings: 'settings'
}
if (routeName && nameToTabMap[routeName]) {
activeTab.value = nameToTabMap[routeName]
}
}
} }
},
{ immediate: true }
) )
// 处理标签切换 // 处理标签切换
const handleTabChange = (tabKey) => { const handleTabChange = async (tabKey) => {
// 如果已经在目标路由,不需要做任何事
if (tabRouteMap[tabKey] === route.path) {
return
}
// 先更新activeTab状态
activeTab.value = tabKey activeTab.value = tabKey
router.push(tabRouteMap[tabKey])
// 使用 await 确保路由切换完成
try {
await router.push(tabRouteMap[tabKey])
// 等待下一个DOM更新周期确保组件正确渲染
await nextTick()
} catch (err) {
// 如果路由切换失败恢复activeTab状态
if (err.name !== 'NavigationDuplicated') {
console.error('路由切换失败:', err)
// 恢复到当前路由对应的tab
initActiveTab()
}
}
} }
// OEM设置已在App.vue中加载无需重复加载 // OEM设置已在App.vue中加载无需重复加载

View File

@@ -1,9 +1,9 @@
<template> <template>
<div class="mb-4 sm:mb-6"> <div class="mb-4 sm:mb-6">
<!-- 移动端下拉选择器 --> <!-- 移动端下拉选择器 -->
<div class="block rounded-xl bg-white/10 p-2 backdrop-blur-sm sm:hidden"> <div class="block rounded-xl bg-white/10 p-2 backdrop-blur-sm dark:bg-gray-800/20 sm:hidden">
<select <select
class="focus:ring-primary-color w-full rounded-lg bg-white/90 px-4 py-3 font-semibold text-gray-700 focus:outline-none focus:ring-2" class="focus:ring-primary-color w-full rounded-lg bg-white/90 px-4 py-3 font-semibold text-gray-700 focus:outline-none focus:ring-2 dark:bg-gray-800/90 dark:text-gray-200 dark:focus:ring-indigo-400"
:value="activeTab" :value="activeTab"
@change="$emit('tab-change', $event.target.value)" @change="$emit('tab-change', $event.target.value)"
> >
@@ -14,13 +14,17 @@
</div> </div>
<!-- 桌面端标签栏 --> <!-- 桌面端标签栏 -->
<div class="hidden flex-wrap gap-2 rounded-2xl bg-white/10 p-2 backdrop-blur-sm sm:flex"> <div
class="hidden flex-wrap gap-2 rounded-2xl bg-white/10 p-2 backdrop-blur-sm dark:bg-gray-800/20 sm:flex"
>
<button <button
v-for="tab in tabs" v-for="tab in tabs"
:key="tab.key" :key="tab.key"
:class="[ :class="[
'tab-btn flex-1 px-3 py-2 text-xs font-semibold transition-all duration-300 sm:px-4 sm:py-3 sm:text-sm md:px-6', 'tab-btn flex-1 px-3 py-2 text-xs font-semibold transition-all duration-300 sm:px-4 sm:py-3 sm:text-sm md:px-6',
activeTab === tab.key ? 'active' : 'text-gray-700 hover:bg-white/10 hover:text-gray-900' activeTab === tab.key
? 'active'
: 'text-gray-700 hover:bg-white/10 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-700/30 dark:hover:text-gray-100'
]" ]"
@click="$emit('tab-change', tab.key)" @click="$emit('tab-change', tab.key)"
> >
@@ -48,7 +52,7 @@ const tabs = [
{ key: 'accounts', name: '账户管理', shortName: '账户', icon: 'fas fa-user-circle' }, { key: 'accounts', name: '账户管理', shortName: '账户', icon: 'fas fa-user-circle' },
{ key: 'userManagement', name: '用户管理', shortName: '用户', icon: 'fas fa-users' }, { key: 'userManagement', name: '用户管理', shortName: '用户', icon: 'fas fa-users' },
{ key: 'tutorial', name: '使用教程', shortName: '教程', icon: 'fas fa-graduation-cap' }, { key: 'tutorial', name: '使用教程', shortName: '教程', icon: 'fas fa-graduation-cap' },
{ key: 'settings', name: '其他设置', shortName: '设置', icon: 'fas fa-cogs' } { key: 'settings', name: '系统设置', shortName: '设置', icon: 'fas fa-cogs' }
] ]
</script> </script>

View File

@@ -170,9 +170,12 @@ class ApiClient {
// DELETE 请求 // DELETE 请求
async delete(url, options = {}) { async delete(url, options = {}) {
const fullUrl = createApiUrl(url) const fullUrl = createApiUrl(url)
const { data, ...restOptions } = options
const config = this.buildConfig({ const config = this.buildConfig({
...options, ...restOptions,
method: 'DELETE' method: 'DELETE',
body: data ? JSON.stringify(data) : undefined
}) })
try { try {

View File

@@ -3,6 +3,7 @@ import { createPinia } from 'pinia'
import ElementPlus from 'element-plus' import ElementPlus from 'element-plus'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs' import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import 'element-plus/dist/index.css' import 'element-plus/dist/index.css'
import 'element-plus/theme-chalk/dark/css-vars.css'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
import { useUserStore } from './stores/user' import { useUserStore } from './stores/user'

View File

@@ -9,6 +9,7 @@ export const useAccountsStore = defineStore('accounts', () => {
const bedrockAccounts = ref([]) const bedrockAccounts = ref([])
const geminiAccounts = ref([]) const geminiAccounts = ref([])
const openaiAccounts = ref([]) const openaiAccounts = ref([])
const azureOpenaiAccounts = ref([])
const loading = ref(false) const loading = ref(false)
const error = ref(null) const error = ref(null)
const sortBy = ref('') const sortBy = ref('')
@@ -111,6 +112,25 @@ export const useAccountsStore = defineStore('accounts', () => {
} }
} }
// 获取Azure OpenAI账户列表
const fetchAzureOpenAIAccounts = async () => {
loading.value = true
error.value = null
try {
const response = await apiClient.get('/admin/azure-openai-accounts')
if (response.success) {
azureOpenaiAccounts.value = response.data || []
} else {
throw new Error(response.message || '获取Azure OpenAI账户失败')
}
} catch (err) {
error.value = err.message
throw err
} finally {
loading.value = false
}
}
// 获取所有账户 // 获取所有账户
const fetchAllAccounts = async () => { const fetchAllAccounts = async () => {
loading.value = true loading.value = true
@@ -121,7 +141,8 @@ export const useAccountsStore = defineStore('accounts', () => {
fetchClaudeConsoleAccounts(), fetchClaudeConsoleAccounts(),
fetchBedrockAccounts(), fetchBedrockAccounts(),
fetchGeminiAccounts(), fetchGeminiAccounts(),
fetchOpenAIAccounts() fetchOpenAIAccounts(),
fetchAzureOpenAIAccounts()
]) ])
} catch (err) { } catch (err) {
error.value = err.message error.value = err.message
@@ -231,6 +252,26 @@ export const useAccountsStore = defineStore('accounts', () => {
} }
} }
// 创建Azure OpenAI账户
const createAzureOpenAIAccount = async (data) => {
loading.value = true
error.value = null
try {
const response = await apiClient.post('/admin/azure-openai-accounts', data)
if (response.success) {
await fetchAzureOpenAIAccounts()
return response.data
} else {
throw new Error(response.message || '创建Azure OpenAI账户失败')
}
} catch (err) {
error.value = err.message
throw err
} finally {
loading.value = false
}
}
// 更新Claude账户 // 更新Claude账户
const updateClaudeAccount = async (id, data) => { const updateClaudeAccount = async (id, data) => {
loading.value = true loading.value = true
@@ -331,6 +372,26 @@ export const useAccountsStore = defineStore('accounts', () => {
} }
} }
// 更新Azure OpenAI账户
const updateAzureOpenAIAccount = async (id, data) => {
loading.value = true
error.value = null
try {
const response = await apiClient.put(`/admin/azure-openai-accounts/${id}`, data)
if (response.success) {
await fetchAzureOpenAIAccounts()
return response
} else {
throw new Error(response.message || '更新Azure OpenAI账户失败')
}
} catch (err) {
error.value = err.message
throw err
} finally {
loading.value = false
}
}
// 切换账户状态 // 切换账户状态
const toggleAccount = async (platform, id) => { const toggleAccount = async (platform, id) => {
loading.value = true loading.value = true
@@ -345,6 +406,10 @@ export const useAccountsStore = defineStore('accounts', () => {
endpoint = `/admin/bedrock-accounts/${id}/toggle` endpoint = `/admin/bedrock-accounts/${id}/toggle`
} else if (platform === 'gemini') { } else if (platform === 'gemini') {
endpoint = `/admin/gemini-accounts/${id}/toggle` endpoint = `/admin/gemini-accounts/${id}/toggle`
} else if (platform === 'openai') {
endpoint = `/admin/openai-accounts/${id}/toggle`
} else if (platform === 'azure_openai') {
endpoint = `/admin/azure-openai-accounts/${id}/toggle`
} else { } else {
endpoint = `/admin/openai-accounts/${id}/toggle` endpoint = `/admin/openai-accounts/${id}/toggle`
} }
@@ -359,6 +424,10 @@ export const useAccountsStore = defineStore('accounts', () => {
await fetchBedrockAccounts() await fetchBedrockAccounts()
} else if (platform === 'gemini') { } else if (platform === 'gemini') {
await fetchGeminiAccounts() await fetchGeminiAccounts()
} else if (platform === 'openai') {
await fetchOpenAIAccounts()
} else if (platform === 'azure_openai') {
await fetchAzureOpenAIAccounts()
} else { } else {
await fetchOpenAIAccounts() await fetchOpenAIAccounts()
} }
@@ -388,6 +457,10 @@ export const useAccountsStore = defineStore('accounts', () => {
endpoint = `/admin/bedrock-accounts/${id}` endpoint = `/admin/bedrock-accounts/${id}`
} else if (platform === 'gemini') { } else if (platform === 'gemini') {
endpoint = `/admin/gemini-accounts/${id}` endpoint = `/admin/gemini-accounts/${id}`
} else if (platform === 'openai') {
endpoint = `/admin/openai-accounts/${id}`
} else if (platform === 'azure_openai') {
endpoint = `/admin/azure-openai-accounts/${id}`
} else { } else {
endpoint = `/admin/openai-accounts/${id}` endpoint = `/admin/openai-accounts/${id}`
} }
@@ -402,6 +475,10 @@ export const useAccountsStore = defineStore('accounts', () => {
await fetchBedrockAccounts() await fetchBedrockAccounts()
} else if (platform === 'gemini') { } else if (platform === 'gemini') {
await fetchGeminiAccounts() await fetchGeminiAccounts()
} else if (platform === 'openai') {
await fetchOpenAIAccounts()
} else if (platform === 'azure_openai') {
await fetchAzureOpenAIAccounts()
} else { } else {
await fetchOpenAIAccounts() await fetchOpenAIAccounts()
} }
@@ -580,6 +657,7 @@ export const useAccountsStore = defineStore('accounts', () => {
bedrockAccounts.value = [] bedrockAccounts.value = []
geminiAccounts.value = [] geminiAccounts.value = []
openaiAccounts.value = [] openaiAccounts.value = []
azureOpenaiAccounts.value = []
loading.value = false loading.value = false
error.value = null error.value = null
sortBy.value = '' sortBy.value = ''
@@ -593,6 +671,7 @@ export const useAccountsStore = defineStore('accounts', () => {
bedrockAccounts, bedrockAccounts,
geminiAccounts, geminiAccounts,
openaiAccounts, openaiAccounts,
azureOpenaiAccounts,
loading, loading,
error, error,
sortBy, sortBy,
@@ -604,17 +683,20 @@ export const useAccountsStore = defineStore('accounts', () => {
fetchBedrockAccounts, fetchBedrockAccounts,
fetchGeminiAccounts, fetchGeminiAccounts,
fetchOpenAIAccounts, fetchOpenAIAccounts,
fetchAzureOpenAIAccounts,
fetchAllAccounts, fetchAllAccounts,
createClaudeAccount, createClaudeAccount,
createClaudeConsoleAccount, createClaudeConsoleAccount,
createBedrockAccount, createBedrockAccount,
createGeminiAccount, createGeminiAccount,
createOpenAIAccount, createOpenAIAccount,
createAzureOpenAIAccount,
updateClaudeAccount, updateClaudeAccount,
updateClaudeConsoleAccount, updateClaudeConsoleAccount,
updateBedrockAccount, updateBedrockAccount,
updateGeminiAccount, updateGeminiAccount,
updateOpenAIAccount, updateOpenAIAccount,
updateAzureOpenAIAccount,
toggleAccount, toggleAccount,
deleteAccount, deleteAccount,
refreshClaudeToken, refreshClaudeToken,

View File

@@ -19,6 +19,8 @@ export const useDashboardStore = defineStore('dashboard', () => {
claude: { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 }, claude: { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 },
'claude-console': { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 }, 'claude-console': { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 },
gemini: { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 }, gemini: { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 },
openai: { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 },
azure_openai: { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 },
bedrock: { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 } bedrock: { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 }
}, },
todayRequests: 0, todayRequests: 0,
@@ -174,6 +176,8 @@ export const useDashboardStore = defineStore('dashboard', () => {
claude: { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 }, claude: { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 },
'claude-console': { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 }, 'claude-console': { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 },
gemini: { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 }, gemini: { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 },
openai: { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 },
azure_openai: { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 },
bedrock: { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 } bedrock: { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 }
}, },
todayRequests: recentActivity.requestsToday || 0, todayRequests: recentActivity.requestsToday || 0,

View File

@@ -0,0 +1,149 @@
import { defineStore } from 'pinia'
import { ref, computed, watch } from 'vue'
// 主题模式枚举
export const ThemeMode = {
LIGHT: 'light',
DARK: 'dark',
AUTO: 'auto'
}
export const useThemeStore = defineStore('theme', () => {
// 状态 - 支持三种模式light, dark, auto
const themeMode = ref(ThemeMode.AUTO)
const systemPrefersDark = ref(false)
// 计算属性 - 实际的暗黑模式状态
const isDarkMode = computed(() => {
if (themeMode.value === ThemeMode.DARK) {
return true
} else if (themeMode.value === ThemeMode.LIGHT) {
return false
} else {
// auto 模式,跟随系统
return systemPrefersDark.value
}
})
// 计算属性 - 当前实际使用的主题
const currentTheme = computed(() => {
return isDarkMode.value ? ThemeMode.DARK : ThemeMode.LIGHT
})
// 初始化主题
const initTheme = () => {
// 检测系统主题偏好
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
systemPrefersDark.value = mediaQuery.matches
// 从 localStorage 读取保存的主题模式
const savedMode = localStorage.getItem('themeMode')
if (savedMode && Object.values(ThemeMode).includes(savedMode)) {
themeMode.value = savedMode
} else {
// 默认使用 auto 模式
themeMode.value = ThemeMode.AUTO
}
// 应用主题
applyTheme()
// 开始监听系统主题变化
watchSystemTheme()
}
// 应用主题到 DOM
const applyTheme = () => {
const root = document.documentElement
if (isDarkMode.value) {
root.classList.add('dark')
} else {
root.classList.remove('dark')
}
}
// 设置主题模式
const setThemeMode = (mode) => {
if (Object.values(ThemeMode).includes(mode)) {
themeMode.value = mode
}
}
// 循环切换主题模式
const cycleThemeMode = () => {
const modes = [ThemeMode.LIGHT, ThemeMode.DARK, ThemeMode.AUTO]
const currentIndex = modes.indexOf(themeMode.value)
const nextIndex = (currentIndex + 1) % modes.length
themeMode.value = modes[nextIndex]
}
// 监听主题模式变化,自动保存到 localStorage 并应用
watch(themeMode, (newMode) => {
localStorage.setItem('themeMode', newMode)
applyTheme()
})
// 监听系统主题偏好变化
watch(systemPrefersDark, () => {
// 只有在 auto 模式下才需要重新应用主题
if (themeMode.value === ThemeMode.AUTO) {
applyTheme()
}
})
// 监听系统主题变化
const watchSystemTheme = () => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
const handleChange = (e) => {
systemPrefersDark.value = e.matches
}
// 初始检测
systemPrefersDark.value = mediaQuery.matches
// 添加监听器
mediaQuery.addEventListener('change', handleChange)
// 返回清理函数
return () => {
mediaQuery.removeEventListener('change', handleChange)
}
}
// 兼容旧版 API
const toggleTheme = () => {
cycleThemeMode()
}
const setTheme = (theme) => {
if (theme === 'dark') {
setThemeMode(ThemeMode.DARK)
} else if (theme === 'light') {
setThemeMode(ThemeMode.LIGHT)
}
}
return {
// State
themeMode,
isDarkMode,
currentTheme,
systemPrefersDark,
// Constants
ThemeMode,
// Actions
initTheme,
setThemeMode,
cycleThemeMode,
watchSystemTheme,
// 兼容旧版 API
toggleTheme,
setTheme
}
})

View File

@@ -3,8 +3,12 @@
<div class="card p-4 sm:p-6"> <div class="card p-4 sm:p-6">
<div class="mb-4 flex flex-col gap-4 sm:mb-6"> <div class="mb-4 flex flex-col gap-4 sm:mb-6">
<div> <div>
<h3 class="mb-1 text-lg font-bold text-gray-900 sm:mb-2 sm:text-xl">账户管理</h3> <h3 class="mb-1 text-lg font-bold text-gray-900 dark:text-gray-100 sm:mb-2 sm:text-xl">
<p class="text-sm text-gray-600 sm:text-base">管理您的 Claude Gemini 账户及代理配置</p> 账户管理
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 sm:text-base">
管理您的 ClaudeGeminiOpenAI Azure OpenAI 账户及代理配置
</p>
</div> </div>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<!-- 筛选器组 --> <!-- 筛选器组 -->
@@ -62,7 +66,7 @@
placement="bottom" placement="bottom"
> >
<button <button
class="group relative flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-all duration-200 hover:border-gray-300 hover:shadow-md disabled:cursor-not-allowed disabled:opacity-50 sm:w-auto" class="group relative flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-all duration-200 hover:border-gray-300 hover:shadow-md disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:border-gray-500 sm:w-auto"
:disabled="accountsLoading" :disabled="accountsLoading"
@click.ctrl.exact="loadAccounts(true)" @click.ctrl.exact="loadAccounts(true)"
@click.exact="loadAccounts(false)" @click.exact="loadAccounts(false)"
@@ -96,26 +100,26 @@
<div v-if="accountsLoading" class="py-12 text-center"> <div v-if="accountsLoading" class="py-12 text-center">
<div class="loading-spinner mx-auto mb-4" /> <div class="loading-spinner mx-auto mb-4" />
<p class="text-gray-500">正在加载账户...</p> <p class="text-gray-500 dark:text-gray-400">正在加载账户...</p>
</div> </div>
<div v-else-if="sortedAccounts.length === 0" class="py-12 text-center"> <div v-else-if="sortedAccounts.length === 0" class="py-12 text-center">
<div <div
class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-gray-100" class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-gray-100 dark:bg-gray-700"
> >
<i class="fas fa-user-circle text-xl text-gray-400" /> <i class="fas fa-user-circle text-xl text-gray-400" />
</div> </div>
<p class="text-lg text-gray-500">暂无账户</p> <p class="text-lg text-gray-500 dark:text-gray-400">暂无账户</p>
<p class="mt-2 text-sm text-gray-400">点击上方按钮添加您的第一个账户</p> <p class="mt-2 text-sm text-gray-400 dark:text-gray-500">点击上方按钮添加您的第一个账户</p>
</div> </div>
<!-- 桌面端表格视图 --> <!-- 桌面端表格视图 -->
<div v-else class="table-container hidden md:block"> <div v-else class="table-container hidden md:block">
<table class="w-full table-fixed"> <table class="w-full table-fixed">
<thead class="bg-gray-50/80 backdrop-blur-sm"> <thead class="bg-gray-50/80 backdrop-blur-sm dark:bg-gray-700/80">
<tr> <tr>
<th <th
class="w-[22%] min-w-[180px] cursor-pointer px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 hover:bg-gray-100" class="w-[22%] min-w-[180px] cursor-pointer px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600"
@click="sortAccounts('name')" @click="sortAccounts('name')"
> >
名称 名称
@@ -130,7 +134,7 @@
<i v-else class="fas fa-sort ml-1 text-gray-400" /> <i v-else class="fas fa-sort ml-1 text-gray-400" />
</th> </th>
<th <th
class="w-[15%] min-w-[120px] cursor-pointer px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 hover:bg-gray-100" class="w-[15%] min-w-[120px] cursor-pointer px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600"
@click="sortAccounts('platform')" @click="sortAccounts('platform')"
> >
平台/类型 平台/类型
@@ -145,7 +149,7 @@
<i v-else class="fas fa-sort ml-1 text-gray-400" /> <i v-else class="fas fa-sort ml-1 text-gray-400" />
</th> </th>
<th <th
class="w-[12%] min-w-[100px] cursor-pointer px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 hover:bg-gray-100" class="w-[12%] min-w-[100px] cursor-pointer px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600"
@click="sortAccounts('status')" @click="sortAccounts('status')"
> >
状态 状态
@@ -160,7 +164,7 @@
<i v-else class="fas fa-sort ml-1 text-gray-400" /> <i v-else class="fas fa-sort ml-1 text-gray-400" />
</th> </th>
<th <th
class="w-[8%] min-w-[80px] cursor-pointer px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 hover:bg-gray-100" class="w-[8%] min-w-[80px] cursor-pointer px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600"
@click="sortAccounts('priority')" @click="sortAccounts('priority')"
> >
优先级 优先级
@@ -175,33 +179,33 @@
<i v-else class="fas fa-sort ml-1 text-gray-400" /> <i v-else class="fas fa-sort ml-1 text-gray-400" />
</th> </th>
<th <th
class="w-[10%] min-w-[100px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700" class="w-[10%] min-w-[100px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
> >
代理 代理
</th> </th>
<th <th
class="w-[10%] min-w-[90px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700" class="w-[10%] min-w-[90px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
> >
今日使用 今日使用
</th> </th>
<th <th
class="w-[10%] min-w-[100px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700" class="w-[10%] min-w-[100px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
> >
会话窗口 会话窗口
</th> </th>
<th <th
class="w-[8%] min-w-[80px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700" class="w-[8%] min-w-[80px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
> >
最后使用 最后使用
</th> </th>
<th <th
class="w-[15%] min-w-[180px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700" class="w-[15%] min-w-[180px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
> >
操作 操作
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-200/50"> <tbody class="divide-y divide-gray-200/50 dark:divide-gray-600/50">
<tr v-for="account in sortedAccounts" :key="account.id" class="table-row"> <tr v-for="account in sortedAccounts" :key="account.id" class="table-row">
<td class="px-3 py-4"> <td class="px-3 py-4">
<div class="flex items-center"> <div class="flex items-center">
@@ -213,7 +217,7 @@
<div class="min-w-0"> <div class="min-w-0">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div <div
class="truncate text-sm font-semibold text-gray-900" class="truncate text-sm font-semibold text-gray-900 dark:text-gray-100"
:title="account.name" :title="account.name"
> >
{{ account.name }} {{ account.name }}
@@ -238,13 +242,16 @@
</span> </span>
<span <span
v-if="account.groupInfo" v-if="account.groupInfo"
class="ml-1 inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600" class="ml-1 inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600 dark:bg-gray-700 dark:text-gray-400"
:title="`所属分组: ${account.groupInfo.name}`" :title="`所属分组: ${account.groupInfo.name}`"
> >
<i class="fas fa-folder mr-1" />{{ account.groupInfo.name }} <i class="fas fa-folder mr-1" />{{ account.groupInfo.name }}
</span> </span>
</div> </div>
<div class="truncate text-xs text-gray-500" :title="account.id"> <div
class="truncate text-xs text-gray-500 dark:text-gray-400"
:title="account.id"
>
{{ account.id }} {{ account.id }}
</div> </div>
</div> </div>
@@ -291,6 +298,19 @@
<span class="mx-1 h-4 w-px bg-gray-400" /> <span class="mx-1 h-4 w-px bg-gray-400" />
<span class="text-xs font-medium text-gray-950">{{ getOpenAIAuthType() }}</span> <span class="text-xs font-medium text-gray-950">{{ getOpenAIAuthType() }}</span>
</div> </div>
<div
v-else-if="account.platform === 'azure_openai'"
class="flex items-center gap-1.5 rounded-lg border border-blue-200 bg-gradient-to-r from-blue-100 to-cyan-100 px-2.5 py-1 dark:border-blue-700 dark:from-blue-900/20 dark:to-cyan-900/20"
>
<i class="fab fa-microsoft text-xs text-blue-700 dark:text-blue-400" />
<span class="text-xs font-semibold text-blue-800 dark:text-blue-300"
>Azure OpenAI</span
>
<span class="mx-1 h-4 w-px bg-blue-300 dark:bg-blue-600" />
<span class="text-xs font-medium text-blue-700 dark:text-blue-400"
>API Key</span
>
</div>
<div <div
v-else-if="account.platform === 'claude' || account.platform === 'claude-oauth'" v-else-if="account.platform === 'claude' || account.platform === 'claude-oauth'"
class="flex items-center gap-1.5 rounded-lg border border-indigo-200 bg-gradient-to-r from-indigo-100 to-blue-100 px-2.5 py-1" class="flex items-center gap-1.5 rounded-lg border border-indigo-200 bg-gradient-to-r from-indigo-100 to-blue-100 px-2.5 py-1"
@@ -376,12 +396,15 @@
</span> </span>
<span <span
v-if="account.status === 'blocked' && account.errorMessage" v-if="account.status === 'blocked' && account.errorMessage"
class="mt-1 max-w-xs truncate text-xs text-gray-500" class="mt-1 max-w-xs truncate text-xs text-gray-500 dark:text-gray-400"
:title="account.errorMessage" :title="account.errorMessage"
> >
{{ account.errorMessage }} {{ account.errorMessage }}
</span> </span>
<span v-if="account.accountType === 'dedicated'" class="text-xs text-gray-500"> <span
v-if="account.accountType === 'dedicated'"
class="text-xs text-gray-500 dark:text-gray-400"
>
绑定: {{ account.boundApiKeysCount || 0 }} 个API Key 绑定: {{ account.boundApiKeysCount || 0 }} 个API Key
</span> </span>
</div> </div>
@@ -403,7 +426,7 @@
:style="{ width: 101 - (account.priority || 50) + '%' }" :style="{ width: 101 - (account.priority || 50) + '%' }"
/> />
</div> </div>
<span class="min-w-[20px] text-xs font-medium text-gray-700"> <span class="min-w-[20px] text-xs font-medium text-gray-700 dark:text-gray-200">
{{ account.priority || 50 }} {{ account.priority || 50 }}
</span> </span>
</div> </div>
@@ -425,19 +448,19 @@
<div v-if="account.usage && account.usage.daily" class="space-y-1"> <div v-if="account.usage && account.usage.daily" class="space-y-1">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div class="h-2 w-2 rounded-full bg-green-500" /> <div class="h-2 w-2 rounded-full bg-green-500" />
<span class="text-sm font-medium text-gray-900" <span class="text-sm font-medium text-gray-900 dark:text-gray-100"
>{{ account.usage.daily.requests || 0 }} 次</span >{{ account.usage.daily.requests || 0 }} 次</span
> >
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div class="h-2 w-2 rounded-full bg-blue-500" /> <div class="h-2 w-2 rounded-full bg-blue-500" />
<span class="text-xs text-gray-600" <span class="text-xs text-gray-600 dark:text-gray-300"
>{{ formatNumber(account.usage.daily.allTokens || 0) }} tokens</span >{{ formatNumber(account.usage.daily.allTokens || 0) }} tokens</span
> >
</div> </div>
<div <div
v-if="account.usage.averages && account.usage.averages.rpm > 0" v-if="account.usage.averages && account.usage.averages.rpm > 0"
class="text-xs text-gray-500" class="text-xs text-gray-500 dark:text-gray-400"
> >
平均 {{ account.usage.averages.rpm.toFixed(2) }} RPM 平均 {{ account.usage.averages.rpm.toFixed(2) }} RPM
</div> </div>
@@ -460,11 +483,11 @@
:style="{ width: account.sessionWindow.progress + '%' }" :style="{ width: account.sessionWindow.progress + '%' }"
/> />
</div> </div>
<span class="min-w-[32px] text-xs font-medium text-gray-700"> <span class="min-w-[32px] text-xs font-medium text-gray-700 dark:text-gray-200">
{{ account.sessionWindow.progress }}% {{ account.sessionWindow.progress }}%
</span> </span>
</div> </div>
<div class="text-xs text-gray-600"> <div class="text-xs text-gray-600 dark:text-gray-300">
<div> <div>
{{ {{
formatSessionWindow( formatSessionWindow(
@@ -488,7 +511,7 @@
<span class="text-xs">N/A</span> <span class="text-xs">N/A</span>
</div> </div>
</td> </td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-600"> <td class="whitespace-nowrap px-3 py-4 text-sm text-gray-600 dark:text-gray-300">
{{ formatLastUsed(account.lastUsedAt) }} {{ formatLastUsed(account.lastUsedAt) }}
</td> </td>
<td class="whitespace-nowrap px-3 py-4 text-sm font-medium"> <td class="whitespace-nowrap px-3 py-4 text-sm font-medium">
@@ -571,6 +594,10 @@
? 'bg-gradient-to-br from-purple-500 to-purple-600' ? 'bg-gradient-to-br from-purple-500 to-purple-600'
: account.platform === 'bedrock' : account.platform === 'bedrock'
? 'bg-gradient-to-br from-orange-500 to-red-600' ? 'bg-gradient-to-br from-orange-500 to-red-600'
: account.platform === 'azure_openai'
? 'bg-gradient-to-br from-blue-500 to-cyan-600'
: account.platform === 'openai'
? 'bg-gradient-to-br from-gray-600 to-gray-700'
: 'bg-gradient-to-br from-blue-500 to-blue-600' : 'bg-gradient-to-br from-blue-500 to-blue-600'
]" ]"
> >
@@ -581,6 +608,10 @@
? 'fas fa-brain' ? 'fas fa-brain'
: account.platform === 'bedrock' : account.platform === 'bedrock'
? 'fab fa-aws' ? 'fab fa-aws'
: account.platform === 'azure_openai'
? 'fab fa-microsoft'
: account.platform === 'openai'
? 'fas fa-openai'
: 'fas fa-robot' : 'fas fa-robot'
]" ]"
/> />
@@ -590,9 +621,11 @@
{{ account.name || account.email }} {{ account.name || account.email }}
</h4> </h4>
<div class="mt-0.5 flex items-center gap-2"> <div class="mt-0.5 flex items-center gap-2">
<span class="text-xs text-gray-500">{{ account.platform }}</span> <span class="text-xs text-gray-500 dark:text-gray-400">{{
account.platform
}}</span>
<span class="text-xs text-gray-400">|</span> <span class="text-xs text-gray-400">|</span>
<span class="text-xs text-gray-500">{{ account.type }}</span> <span class="text-xs text-gray-500 dark:text-gray-400">{{ account.type }}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -612,20 +645,20 @@
<!-- 使用统计 --> <!-- 使用统计 -->
<div class="mb-3 grid grid-cols-2 gap-3"> <div class="mb-3 grid grid-cols-2 gap-3">
<div> <div>
<p class="text-xs text-gray-500">今日使用</p> <p class="text-xs text-gray-500 dark:text-gray-400">今日使用</p>
<p class="text-sm font-semibold text-gray-900"> <p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ formatNumber(account.usage?.daily?.requests || 0) }} 次 {{ formatNumber(account.usage?.daily?.requests || 0) }} 次
</p> </p>
<p class="mt-0.5 text-xs text-gray-500"> <p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ formatNumber(account.usage?.daily?.allTokens || 0) }} tokens {{ formatNumber(account.usage?.daily?.allTokens || 0) }} tokens
</p> </p>
</div> </div>
<div> <div>
<p class="text-xs text-gray-500">总使用量</p> <p class="text-xs text-gray-500 dark:text-gray-400">总使用量</p>
<p class="text-sm font-semibold text-gray-900"> <p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ formatNumber(account.usage?.total?.requests || 0) }} 次 {{ formatNumber(account.usage?.total?.requests || 0) }} 次
</p> </p>
<p class="mt-0.5 text-xs text-gray-500"> <p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ formatNumber(account.usage?.total?.allTokens || 0) }} tokens {{ formatNumber(account.usage?.total?.allTokens || 0) }} tokens
</p> </p>
</div> </div>
@@ -640,22 +673,22 @@
account.sessionWindow && account.sessionWindow &&
account.sessionWindow.hasActiveWindow account.sessionWindow.hasActiveWindow
" "
class="space-y-1.5 rounded-lg bg-gray-50 p-2" class="space-y-1.5 rounded-lg bg-gray-50 p-2 dark:bg-gray-700"
> >
<div class="flex items-center justify-between text-xs"> <div class="flex items-center justify-between text-xs">
<span class="font-medium text-gray-600">会话窗口</span> <span class="font-medium text-gray-600 dark:text-gray-300">会话窗口</span>
<span class="font-medium text-gray-700"> <span class="font-medium text-gray-700 dark:text-gray-200">
{{ account.sessionWindow.progress }}% {{ account.sessionWindow.progress }}%
</span> </span>
</div> </div>
<div class="h-2 w-full overflow-hidden rounded-full bg-gray-200"> <div class="h-2 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-gray-600">
<div <div
class="h-full bg-gradient-to-r from-blue-500 to-indigo-600 transition-all duration-300" class="h-full bg-gradient-to-r from-blue-500 to-indigo-600 transition-all duration-300"
:style="{ width: account.sessionWindow.progress + '%' }" :style="{ width: account.sessionWindow.progress + '%' }"
/> />
</div> </div>
<div class="flex items-center justify-between text-xs"> <div class="flex items-center justify-between text-xs">
<span class="text-gray-500"> <span class="text-gray-500 dark:text-gray-400">
{{ {{
formatSessionWindow( formatSessionWindow(
account.sessionWindow.windowStart, account.sessionWindow.windowStart,
@@ -675,8 +708,8 @@
<!-- 最后使用时间 --> <!-- 最后使用时间 -->
<div class="flex items-center justify-between text-xs"> <div class="flex items-center justify-between text-xs">
<span class="text-gray-500">最后使用</span> <span class="text-gray-500 dark:text-gray-400">最后使用</span>
<span class="text-gray-700"> <span class="text-gray-700 dark:text-gray-200">
{{ account.lastUsedAt ? formatRelativeTime(account.lastUsedAt) : '从未使用' }} {{ account.lastUsedAt ? formatRelativeTime(account.lastUsedAt) : '从未使用' }}
</span> </span>
</div> </div>
@@ -686,16 +719,16 @@
v-if="account.proxyConfig && account.proxyConfig.type !== 'none'" v-if="account.proxyConfig && account.proxyConfig.type !== 'none'"
class="flex items-center justify-between text-xs" class="flex items-center justify-between text-xs"
> >
<span class="text-gray-500">代理</span> <span class="text-gray-500 dark:text-gray-400">代理</span>
<span class="text-gray-700"> <span class="text-gray-700 dark:text-gray-200">
{{ account.proxyConfig.type.toUpperCase() }} {{ account.proxyConfig.type.toUpperCase() }}
</span> </span>
</div> </div>
<!-- 调度优先级 --> <!-- 调度优先级 -->
<div class="flex items-center justify-between text-xs"> <div class="flex items-center justify-between text-xs">
<span class="text-gray-500">优先级</span> <span class="text-gray-500 dark:text-gray-400">优先级</span>
<span class="font-medium text-gray-700"> <span class="font-medium text-gray-700 dark:text-gray-200">
{{ account.priority || 50 }} {{ account.priority || 50 }}
</span> </span>
</div> </div>
@@ -808,6 +841,7 @@ const platformOptions = ref([
{ value: 'claude-console', label: 'Claude Console', icon: 'fa-terminal' }, { value: 'claude-console', label: 'Claude Console', icon: 'fa-terminal' },
{ value: 'gemini', label: 'Gemini', icon: 'fa-google' }, { value: 'gemini', label: 'Gemini', icon: 'fa-google' },
{ value: 'openai', label: 'OpenAi', icon: 'fa-openai' }, { value: 'openai', label: 'OpenAi', icon: 'fa-openai' },
{ value: 'azure_openai', label: 'Azure OpenAI', icon: 'fab fa-microsoft' },
{ value: 'bedrock', label: 'Bedrock', icon: 'fab fa-aws' } { value: 'bedrock', label: 'Bedrock', icon: 'fab fa-aws' }
]) ])
@@ -900,7 +934,8 @@ const loadAccounts = async (forceReload = false) => {
apiClient.get('/admin/claude-console-accounts', { params }), apiClient.get('/admin/claude-console-accounts', { params }),
apiClient.get('/admin/bedrock-accounts', { params }), apiClient.get('/admin/bedrock-accounts', { params }),
apiClient.get('/admin/gemini-accounts', { params }), apiClient.get('/admin/gemini-accounts', { params }),
apiClient.get('/admin/openai-accounts', { params }) apiClient.get('/admin/openai-accounts', { params }),
apiClient.get('/admin/azure-openai-accounts', { params })
) )
} else { } else {
// 只请求指定平台其他平台设为null占位 // 只请求指定平台其他平台设为null占位
@@ -946,7 +981,7 @@ const loadAccounts = async (forceReload = false) => {
// 加载分组成员关系(需要在分组数据加载完成后) // 加载分组成员关系(需要在分组数据加载完成后)
await loadGroupMembers(forceReload) await loadGroupMembers(forceReload)
const [claudeData, claudeConsoleData, bedrockData, geminiData, openaiData] = const [claudeData, claudeConsoleData, bedrockData, geminiData, openaiData, azureOpenaiData] =
await Promise.all(requests) await Promise.all(requests)
const allAccounts = [] const allAccounts = []
@@ -1004,6 +1039,17 @@ const loadAccounts = async (forceReload = false) => {
}) })
allAccounts.push(...openaiAccounts) allAccounts.push(...openaiAccounts)
} }
if (azureOpenaiData && azureOpenaiData.success) {
const azureOpenaiAccounts = (azureOpenaiData.data || []).map((acc) => {
// 计算每个Azure OpenAI账户绑定的API Key数量
const boundApiKeysCount = apiKeys.value.filter(
(key) => key.azureOpenaiAccountId === acc.id
).length
const groupInfo = accountGroupMap.value.get(acc.id) || null
return { ...acc, platform: 'azure_openai', boundApiKeysCount, groupInfo }
})
allAccounts.push(...azureOpenaiAccounts)
}
accounts.value = allAccounts accounts.value = allAccounts
} catch (error) { } catch (error) {
@@ -1232,6 +1278,8 @@ const deleteAccount = async (account) => {
endpoint = `/admin/bedrock-accounts/${account.id}` endpoint = `/admin/bedrock-accounts/${account.id}`
} else if (account.platform === 'openai') { } else if (account.platform === 'openai') {
endpoint = `/admin/openai-accounts/${account.id}` endpoint = `/admin/openai-accounts/${account.id}`
} else if (account.platform === 'azure_openai') {
endpoint = `/admin/azure-openai-accounts/${account.id}`
} else { } else {
endpoint = `/admin/gemini-accounts/${account.id}` endpoint = `/admin/gemini-accounts/${account.id}`
} }
@@ -1304,6 +1352,8 @@ const toggleSchedulable = async (account) => {
endpoint = `/admin/gemini-accounts/${account.id}/toggle-schedulable` endpoint = `/admin/gemini-accounts/${account.id}/toggle-schedulable`
} else if (account.platform === 'openai') { } else if (account.platform === 'openai') {
endpoint = `/admin/openai-accounts/${account.id}/toggle-schedulable` endpoint = `/admin/openai-accounts/${account.id}/toggle-schedulable`
} else if (account.platform === 'azure_openai') {
endpoint = `/admin/azure-openai-accounts/${account.id}/toggle-schedulable`
} else { } else {
showToast('该账户类型暂不支持调度控制', 'warning') showToast('该账户类型暂不支持调度控制', 'warning')
return return

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="gradient-bg min-h-screen p-4 md:p-6"> <div class="min-h-screen p-4 md:p-6" :class="isDarkMode ? 'gradient-bg-dark' : 'gradient-bg'">
<!-- 顶部导航 --> <!-- 顶部导航 -->
<div class="glass-strong mb-6 rounded-3xl p-4 shadow-xl md:mb-8 md:p-6"> <div class="glass-strong mb-6 rounded-3xl p-4 shadow-xl md:mb-8 md:p-6">
<div class="flex flex-col items-center justify-between gap-4 md:flex-row"> <div class="flex flex-col items-center justify-between gap-4 md:flex-row">
@@ -9,7 +9,18 @@
:subtitle="currentTab === 'stats' ? 'API Key 使用统计' : '使用教程'" :subtitle="currentTab === 'stats' ? 'API Key 使用统计' : '使用教程'"
:title="oemSettings.siteName" :title="oemSettings.siteName"
/> />
<div class="flex items-center gap-3"> <div class="flex items-center gap-2 md:gap-4">
<!-- 主题切换按钮 -->
<div class="flex items-center">
<ThemeToggle mode="dropdown" />
</div>
<!-- 分隔线 -->
<div
class="h-8 w-px bg-gradient-to-b from-transparent via-gray-300 to-transparent opacity-50 dark:via-gray-600"
/>
<!-- 管理后台按钮 -->
<router-link <router-link
class="user-login-button flex items-center gap-2 rounded-xl px-3 py-2 text-white transition-all duration-300 md:px-4 md:py-2" class="user-login-button flex items-center gap-2 rounded-xl px-3 py-2 text-white transition-all duration-300 md:px-4 md:py-2"
to="/user-login" to="/user-login"
@@ -18,11 +29,11 @@
<span class="text-xs font-medium md:text-sm">用户登录</span> <span class="text-xs font-medium md:text-sm">用户登录</span>
</router-link> </router-link>
<router-link <router-link
class="admin-button flex items-center gap-2 rounded-xl px-3 py-2 text-white transition-all duration-300 md:px-4 md:py-2" class="admin-button-refined flex items-center gap-2 rounded-2xl px-4 py-2 transition-all duration-300 md:px-5 md:py-2.5"
to="/dashboard" to="/dashboard"
> >
<i class="fas fa-cog text-sm" /> <i class="fas fa-shield-alt text-sm md:text-base" />
<span class="text-xs font-medium md:text-sm">管理后台</span> <span class="text-xs font-semibold tracking-wide md:text-sm">管理后台</span>
</router-link> </router-link>
</div> </div>
</div> </div>
@@ -60,7 +71,7 @@
<!-- 错误提示 --> <!-- 错误提示 -->
<div v-if="error" class="mb-6 md:mb-8"> <div v-if="error" class="mb-6 md:mb-8">
<div <div
class="rounded-xl border border-red-500/30 bg-red-500/20 p-3 text-sm text-red-800 backdrop-blur-sm md:p-4 md:text-base" class="rounded-xl border border-red-500/30 bg-red-500/20 p-3 text-sm text-red-800 backdrop-blur-sm dark:border-red-500/20 dark:bg-red-500/10 dark:text-red-200 md:p-4 md:text-base"
> >
<i class="fas fa-exclamation-triangle mr-2" /> <i class="fas fa-exclamation-triangle mr-2" />
{{ error }} {{ error }}
@@ -71,13 +82,15 @@
<div v-if="statsData" class="fade-in"> <div v-if="statsData" class="fade-in">
<div class="glass-strong rounded-3xl p-4 shadow-xl md:p-6"> <div class="glass-strong rounded-3xl p-4 shadow-xl md:p-6">
<!-- 时间范围选择器 --> <!-- 时间范围选择器 -->
<div class="mb-4 border-b border-gray-200 pb-4 md:mb-6 md:pb-6"> <div class="mb-4 border-b border-gray-200 pb-4 dark:border-gray-700 md:mb-6 md:pb-6">
<div <div
class="flex flex-col items-start justify-between gap-3 md:flex-row md:items-center md:gap-4" class="flex flex-col items-start justify-between gap-3 md:flex-row md:items-center md:gap-4"
> >
<div class="flex items-center gap-2 md:gap-3"> <div class="flex items-center gap-2 md:gap-3">
<i class="fas fa-clock text-base text-blue-500 md:text-lg" /> <i class="fas fa-clock text-base text-blue-500 md:text-lg" />
<span class="text-base font-medium text-gray-700 md:text-lg">统计时间范围</span> <span class="text-base font-medium text-gray-700 dark:text-gray-200 md:text-lg"
>统计时间范围</span
>
</div> </div>
<div class="flex w-full gap-2 md:w-auto"> <div class="flex w-full gap-2 md:w-auto">
<button <button
@@ -127,11 +140,13 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted, onUnmounted, watch } from 'vue' import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { useApiStatsStore } from '@/stores/apistats' import { useApiStatsStore } from '@/stores/apistats'
import { useThemeStore } from '@/stores/theme'
import LogoTitle from '@/components/common/LogoTitle.vue' import LogoTitle from '@/components/common/LogoTitle.vue'
import ThemeToggle from '@/components/common/ThemeToggle.vue'
import ApiKeyInput from '@/components/apistats/ApiKeyInput.vue' import ApiKeyInput from '@/components/apistats/ApiKeyInput.vue'
import StatsOverview from '@/components/apistats/StatsOverview.vue' import StatsOverview from '@/components/apistats/StatsOverview.vue'
import TokenDistribution from '@/components/apistats/TokenDistribution.vue' import TokenDistribution from '@/components/apistats/TokenDistribution.vue'
@@ -141,10 +156,14 @@ import TutorialView from './TutorialView.vue'
const route = useRoute() const route = useRoute()
const apiStatsStore = useApiStatsStore() const apiStatsStore = useApiStatsStore()
const themeStore = useThemeStore()
// 当前标签页 // 当前标签页
const currentTab = ref('stats') const currentTab = ref('stats')
// 主题相关
const isDarkMode = computed(() => themeStore.isDarkMode)
const { const {
apiKey, apiKey,
apiId, apiId,
@@ -179,6 +198,9 @@ const handleKeyDown = (event) => {
onMounted(() => { onMounted(() => {
console.log('API Stats Page loaded') console.log('API Stats Page loaded')
// 初始化主题(因为该页面不在 MainLayout 内)
themeStore.initTheme()
// 加载 OEM 设置 // 加载 OEM 设置
loadOemSettings() loadOemSettings()
@@ -224,6 +246,14 @@ watch(apiKey, (newValue) => {
position: relative; position: relative;
} }
/* 暗色模式的渐变背景 */
.gradient-bg-dark {
background: linear-gradient(135deg, #1e293b 0%, #334155 50%, #475569 100%);
background-attachment: fixed;
min-height: 100vh;
position: relative;
}
.gradient-bg::before { .gradient-bg::before {
content: ''; content: '';
position: fixed; position: fixed;
@@ -239,11 +269,27 @@ watch(apiKey, (newValue) => {
z-index: 0; z-index: 0;
} }
/* 玻璃态效果 */ /* 暗色模式的背景覆盖 */
.gradient-bg-dark::before {
content: '';
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background:
radial-gradient(circle at 20% 80%, rgba(100, 116, 139, 0.1) 0%, transparent 50%),
radial-gradient(circle at 80% 20%, rgba(71, 85, 105, 0.1) 0%, transparent 50%),
radial-gradient(circle at 40% 40%, rgba(30, 41, 59, 0.1) 0%, transparent 50%);
pointer-events: none;
z-index: 0;
}
/* 玻璃态效果 - 使用CSS变量 */
.glass-strong { .glass-strong {
background: rgba(255, 255, 255, 0.95); background: var(--glass-strong-color);
backdrop-filter: blur(25px); backdrop-filter: blur(25px);
border: 1px solid rgba(255, 255, 255, 0.3); border: 1px solid var(--border-color);
box-shadow: box-shadow:
0 25px 50px -12px rgba(0, 0, 0, 0.25), 0 25px 50px -12px rgba(0, 0, 0, 0.25),
0 0 0 1px rgba(255, 255, 255, 0.05), 0 0 0 1px rgba(255, 255, 255, 0.05),
@@ -252,6 +298,14 @@ watch(apiKey, (newValue) => {
z-index: 1; z-index: 1;
} }
/* 暗色模式的玻璃态效果 */
:global(.dark) .glass-strong {
box-shadow:
0 25px 50px -12px rgba(0, 0, 0, 0.7),
0 0 0 1px rgba(55, 65, 81, 0.3),
inset 0 1px 0 rgba(75, 85, 99, 0.2);
}
/* 标题渐变 */ /* 标题渐变 */
.header-title { .header-title {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
@@ -296,38 +350,76 @@ watch(apiKey, (newValue) => {
left: 100%; left: 100%;
} }
/* 管理后台按钮 */ /* 管理后台按钮 - 精致版本 */
.admin-button { .admin-button-refined {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: 1px solid rgba(255, 255, 255, 0.2); backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
text-decoration: none; text-decoration: none;
box-shadow: box-shadow:
0 4px 6px -1px rgba(102, 126, 234, 0.3), 0 4px 12px rgba(102, 126, 234, 0.25),
0 2px 4px -1px rgba(102, 126, 234, 0.1); inset 0 1px 1px rgba(255, 255, 255, 0.2);
position: relative; position: relative;
overflow: hidden; overflow: hidden;
font-weight: 600;
} }
.admin-button::before { /* 暗色模式下的管理后台按钮 */
:global(.dark) .admin-button-refined {
background: rgba(55, 65, 81, 0.8);
border: 1px solid rgba(107, 114, 128, 0.4);
color: #f3f4f6;
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.3),
inset 0 1px 1px rgba(255, 255, 255, 0.05);
}
.admin-button-refined::before {
content: ''; content: '';
position: absolute; position: absolute;
top: 0; top: 0;
left: -100%; left: 0;
width: 100%; right: 0;
height: 100%; bottom: 0;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent); background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
transition: left 0.5s; opacity: 0;
transition: opacity 0.3s ease;
} }
.admin-button:hover { .admin-button-refined:hover {
transform: translateY(-2px); transform: translateY(-2px) scale(1.02);
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
box-shadow: box-shadow:
0 10px 15px -3px rgba(102, 126, 234, 0.4), 0 8px 20px rgba(118, 75, 162, 0.35),
0 4px 6px -2px rgba(102, 126, 234, 0.15); inset 0 1px 1px rgba(255, 255, 255, 0.3);
border-color: rgba(255, 255, 255, 0.4);
color: white;
} }
.admin-button:hover::before { .admin-button-refined:hover::before {
left: 100%; opacity: 1;
}
/* 暗色模式下的悬停效果 */
:global(.dark) .admin-button-refined:hover {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-color: rgba(147, 51, 234, 0.4);
box-shadow:
0 8px 20px rgba(102, 126, 234, 0.3),
inset 0 1px 1px rgba(255, 255, 255, 0.1);
color: white;
}
.admin-button-refined:active {
transform: translateY(-1px) scale(1);
}
/* 确保图标和文字在所有模式下都清晰可见 */
.admin-button-refined i,
.admin-button-refined span {
position: relative;
z-index: 1;
} }
/* 时间范围按钮 */ /* 时间范围按钮 */
@@ -353,12 +445,26 @@ watch(apiKey, (newValue) => {
.period-btn:not(.active) { .period-btn:not(.active) {
color: #374151; color: #374151;
background: transparent; background: rgba(255, 255, 255, 0.6);
border: 1px solid rgba(229, 231, 235, 0.5);
}
:global(html.dark) .period-btn:not(.active) {
color: #e5e7eb;
background: rgba(55, 65, 81, 0.4);
border: 1px solid rgba(75, 85, 99, 0.5);
} }
.period-btn:not(.active):hover { .period-btn:not(.active):hover {
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.8);
color: #1f2937; color: #1f2937;
border-color: rgba(209, 213, 219, 0.8);
}
:global(html.dark) .period-btn:not(.active):hover {
background: rgba(75, 85, 99, 0.6);
color: #ffffff;
border-color: rgba(107, 114, 128, 0.8);
} }
/* Tab 胶囊按钮样式 */ /* Tab 胶囊按钮样式 */
@@ -380,6 +486,11 @@ watch(apiKey, (newValue) => {
justify-content: center; justify-content: center;
} }
/* 暗夜模式下的Tab按钮基础样式 */
:global(html.dark) .tab-pill-button {
color: rgba(209, 213, 219, 0.8);
}
@media (min-width: 768px) { @media (min-width: 768px) {
.tab-pill-button { .tab-pill-button {
padding: 0.625rem 1.25rem; padding: 0.625rem 1.25rem;
@@ -392,6 +503,11 @@ watch(apiKey, (newValue) => {
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.1);
} }
:global(html.dark) .tab-pill-button:hover {
color: #f3f4f6;
background: rgba(100, 116, 139, 0.2);
}
.tab-pill-button.active { .tab-pill-button.active {
background: white; background: white;
color: #764ba2; color: #764ba2;
@@ -400,6 +516,14 @@ watch(apiKey, (newValue) => {
0 2px 4px -1px rgba(0, 0, 0, 0.06); 0 2px 4px -1px rgba(0, 0, 0, 0.06);
} }
:global(html.dark) .tab-pill-button.active {
background: rgba(71, 85, 105, 0.9);
color: #f3f4f6;
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.3),
0 2px 4px -1px rgba(0, 0, 0, 0.2);
}
.tab-pill-button i { .tab-pill-button i {
font-size: 0.875rem; font-size: 0.875rem;
} }

View File

@@ -7,11 +7,15 @@
<div class="stat-card"> <div class="stat-card">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<p class="mb-1 text-xs font-semibold text-gray-600 sm:text-sm">总API Keys</p> <p class="mb-1 text-xs font-semibold text-gray-600 dark:text-gray-400 sm:text-sm">
<p class="text-2xl font-bold text-gray-900 sm:text-3xl"> 总API Keys
</p>
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100 sm:text-3xl">
{{ dashboardData.totalApiKeys }} {{ dashboardData.totalApiKeys }}
</p> </p>
<p class="mt-1 text-xs text-gray-500">活跃: {{ dashboardData.activeApiKeys || 0 }}</p> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
活跃: {{ dashboardData.activeApiKeys || 0 }}
</p>
</div> </div>
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-blue-500 to-blue-600"> <div class="stat-icon flex-shrink-0 bg-gradient-to-br from-blue-500 to-blue-600">
<i class="fas fa-key" /> <i class="fas fa-key" />
@@ -22,9 +26,11 @@
<div class="stat-card"> <div class="stat-card">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex-1"> <div class="flex-1">
<p class="mb-1 text-xs font-semibold text-gray-600 sm:text-sm">服务账户</p> <p class="mb-1 text-xs font-semibold text-gray-600 dark:text-gray-400 sm:text-sm">
服务账户
</p>
<div class="flex flex-wrap items-baseline gap-x-2"> <div class="flex flex-wrap items-baseline gap-x-2">
<p class="text-2xl font-bold text-gray-900 sm:text-3xl"> <p class="text-2xl font-bold text-gray-900 dark:text-gray-100 sm:text-3xl">
{{ dashboardData.totalAccounts }} {{ dashboardData.totalAccounts }}
</p> </p>
<!-- 各平台账户数量展示 --> <!-- 各平台账户数量展示 -->
@@ -39,7 +45,7 @@
:title="`Claude: ${dashboardData.accountsByPlatform.claude.total} 个 (正常: ${dashboardData.accountsByPlatform.claude.normal})`" :title="`Claude: ${dashboardData.accountsByPlatform.claude.total} 个 (正常: ${dashboardData.accountsByPlatform.claude.normal})`"
> >
<i class="fas fa-brain text-xs text-indigo-600" /> <i class="fas fa-brain text-xs text-indigo-600" />
<span class="text-xs font-medium text-gray-700">{{ <span class="text-xs font-medium text-gray-700 dark:text-gray-300">{{
dashboardData.accountsByPlatform.claude.total dashboardData.accountsByPlatform.claude.total
}}</span> }}</span>
</div> </div>
@@ -53,7 +59,7 @@
:title="`Console: ${dashboardData.accountsByPlatform['claude-console'].total} 个 (正常: ${dashboardData.accountsByPlatform['claude-console'].normal})`" :title="`Console: ${dashboardData.accountsByPlatform['claude-console'].total} 个 (正常: ${dashboardData.accountsByPlatform['claude-console'].normal})`"
> >
<i class="fas fa-terminal text-xs text-purple-600" /> <i class="fas fa-terminal text-xs text-purple-600" />
<span class="text-xs font-medium text-gray-700">{{ <span class="text-xs font-medium text-gray-700 dark:text-gray-300">{{
dashboardData.accountsByPlatform['claude-console'].total dashboardData.accountsByPlatform['claude-console'].total
}}</span> }}</span>
</div> </div>
@@ -67,7 +73,7 @@
:title="`Gemini: ${dashboardData.accountsByPlatform.gemini.total} 个 (正常: ${dashboardData.accountsByPlatform.gemini.normal})`" :title="`Gemini: ${dashboardData.accountsByPlatform.gemini.total} 个 (正常: ${dashboardData.accountsByPlatform.gemini.normal})`"
> >
<i class="fas fa-robot text-xs text-yellow-600" /> <i class="fas fa-robot text-xs text-yellow-600" />
<span class="text-xs font-medium text-gray-700">{{ <span class="text-xs font-medium text-gray-700 dark:text-gray-300">{{
dashboardData.accountsByPlatform.gemini.total dashboardData.accountsByPlatform.gemini.total
}}</span> }}</span>
</div> </div>
@@ -81,7 +87,7 @@
:title="`Bedrock: ${dashboardData.accountsByPlatform.bedrock.total} 个 (正常: ${dashboardData.accountsByPlatform.bedrock.normal})`" :title="`Bedrock: ${dashboardData.accountsByPlatform.bedrock.total} 个 (正常: ${dashboardData.accountsByPlatform.bedrock.normal})`"
> >
<i class="fab fa-aws text-xs text-orange-600" /> <i class="fab fa-aws text-xs text-orange-600" />
<span class="text-xs font-medium text-gray-700">{{ <span class="text-xs font-medium text-gray-700 dark:text-gray-300">{{
dashboardData.accountsByPlatform.bedrock.total dashboardData.accountsByPlatform.bedrock.total
}}</span> }}</span>
</div> </div>
@@ -95,18 +101,35 @@
:title="`OpenAI: ${dashboardData.accountsByPlatform.openai.total} 个 (正常: ${dashboardData.accountsByPlatform.openai.normal})`" :title="`OpenAI: ${dashboardData.accountsByPlatform.openai.total} 个 (正常: ${dashboardData.accountsByPlatform.openai.normal})`"
> >
<i class="fas fa-openai text-xs text-gray-100" /> <i class="fas fa-openai text-xs text-gray-100" />
<span class="text-xs font-medium text-gray-700">{{ <span class="text-xs font-medium text-gray-700 dark:text-gray-300">{{
dashboardData.accountsByPlatform.openai.total dashboardData.accountsByPlatform.openai.total
}}</span> }}</span>
</div> </div>
<!-- Azure OpenAI账户 -->
<div
v-if="
dashboardData.accountsByPlatform.azure_openai &&
dashboardData.accountsByPlatform.azure_openai.total > 0
"
class="inline-flex items-center gap-0.5"
:title="`Azure OpenAI: ${dashboardData.accountsByPlatform.azure_openai.total} 个 (正常: ${dashboardData.accountsByPlatform.azure_openai.normal})`"
>
<i class="fab fa-microsoft text-xs text-blue-600" />
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">{{
dashboardData.accountsByPlatform.azure_openai.total
}}</span>
</div>
</div> </div>
</div> </div>
<p class="mt-1 text-xs text-gray-500"> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
正常: {{ dashboardData.normalAccounts || 0 }} 正常: {{ dashboardData.normalAccounts || 0 }}
<span v-if="dashboardData.abnormalAccounts > 0" class="text-red-600"> <span v-if="dashboardData.abnormalAccounts > 0" class="text-red-600">
| 异常: {{ dashboardData.abnormalAccounts }} | 异常: {{ dashboardData.abnormalAccounts }}
</span> </span>
<span v-if="dashboardData.pausedAccounts > 0" class="text-gray-600"> <span
v-if="dashboardData.pausedAccounts > 0"
class="text-gray-600 dark:text-gray-400"
>
| 停止调度: {{ dashboardData.pausedAccounts }} | 停止调度: {{ dashboardData.pausedAccounts }}
</span> </span>
<span v-if="dashboardData.rateLimitedAccounts > 0" class="text-yellow-600"> <span v-if="dashboardData.rateLimitedAccounts > 0" class="text-yellow-600">
@@ -123,11 +146,13 @@
<div class="stat-card"> <div class="stat-card">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<p class="mb-1 text-xs font-semibold text-gray-600 sm:text-sm">今日请求</p> <p class="mb-1 text-xs font-semibold text-gray-600 dark:text-gray-400 sm:text-sm">
<p class="text-2xl font-bold text-gray-900 sm:text-3xl"> 今日请求
</p>
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100 sm:text-3xl">
{{ dashboardData.todayRequests }} {{ dashboardData.todayRequests }}
</p> </p>
<p class="mt-1 text-xs text-gray-500"> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
总请求: {{ formatNumber(dashboardData.totalRequests || 0) }} 总请求: {{ formatNumber(dashboardData.totalRequests || 0) }}
</p> </p>
</div> </div>
@@ -140,11 +165,15 @@
<div class="stat-card"> <div class="stat-card">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<p class="mb-1 text-xs font-semibold text-gray-600 sm:text-sm">系统状态</p> <p class="mb-1 text-xs font-semibold text-gray-600 dark:text-gray-400 sm:text-sm">
系统状态
</p>
<p class="text-2xl font-bold text-green-600 sm:text-3xl"> <p class="text-2xl font-bold text-green-600 sm:text-3xl">
{{ dashboardData.systemStatus }} {{ dashboardData.systemStatus }}
</p> </p>
<p class="mt-1 text-xs text-gray-500">运行时间: {{ formattedUptime }}</p> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
运行时间: {{ formattedUptime }}
</p>
</div> </div>
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-yellow-500 to-orange-500"> <div class="stat-icon flex-shrink-0 bg-gradient-to-br from-yellow-500 to-orange-500">
<i class="fas fa-heartbeat" /> <i class="fas fa-heartbeat" />
@@ -160,7 +189,9 @@
<div class="stat-card"> <div class="stat-card">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="mr-8 flex-1"> <div class="mr-8 flex-1">
<p class="mb-1 text-xs font-semibold text-gray-600 sm:text-sm">今日Token</p> <p class="mb-1 text-xs font-semibold text-gray-600 dark:text-gray-400 sm:text-sm">
今日Token
</p>
<div class="mb-2 flex flex-wrap items-baseline gap-2"> <div class="mb-2 flex flex-wrap items-baseline gap-2">
<p class="text-xl font-bold text-blue-600 sm:text-2xl md:text-3xl"> <p class="text-xl font-bold text-blue-600 sm:text-2xl md:text-3xl">
{{ {{
@@ -176,7 +207,7 @@
>/ {{ costsData.todayCosts.formatted.totalCost }}</span >/ {{ costsData.todayCosts.formatted.totalCost }}</span
> >
</div> </div>
<div class="text-xs text-gray-500"> <div class="text-xs text-gray-500 dark:text-gray-400">
<div class="flex flex-wrap items-center justify-between gap-x-4"> <div class="flex flex-wrap items-center justify-between gap-x-4">
<span <span
>输入: >输入:
@@ -214,7 +245,9 @@
<div class="stat-card"> <div class="stat-card">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="mr-8 flex-1"> <div class="mr-8 flex-1">
<p class="mb-1 text-xs font-semibold text-gray-600 sm:text-sm">总Token消耗</p> <p class="mb-1 text-xs font-semibold text-gray-600 dark:text-gray-400 sm:text-sm">
总Token消耗
</p>
<div class="mb-2 flex flex-wrap items-baseline gap-2"> <div class="mb-2 flex flex-wrap items-baseline gap-2">
<p class="text-xl font-bold text-emerald-600 sm:text-2xl md:text-3xl"> <p class="text-xl font-bold text-emerald-600 sm:text-2xl md:text-3xl">
{{ {{
@@ -230,7 +263,7 @@
>/ {{ costsData.totalCosts.formatted.totalCost }}</span >/ {{ costsData.totalCosts.formatted.totalCost }}</span
> >
</div> </div>
<div class="text-xs text-gray-500"> <div class="text-xs text-gray-500 dark:text-gray-400">
<div class="flex flex-wrap items-center justify-between gap-x-4"> <div class="flex flex-wrap items-center justify-between gap-x-4">
<span <span
>输入: >输入:
@@ -268,14 +301,14 @@
<div class="stat-card"> <div class="stat-card">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<p class="mb-1 text-xs font-semibold text-gray-600 sm:text-sm"> <p class="mb-1 text-xs font-semibold text-gray-600 dark:text-gray-400 sm:text-sm">
实时RPM 实时RPM
<span class="text-xs text-gray-400">({{ dashboardData.metricsWindow }}分钟)</span> <span class="text-xs text-gray-400">({{ dashboardData.metricsWindow }}分钟)</span>
</p> </p>
<p class="text-2xl font-bold text-orange-600 sm:text-3xl"> <p class="text-2xl font-bold text-orange-600 sm:text-3xl">
{{ dashboardData.realtimeRPM || 0 }} {{ dashboardData.realtimeRPM || 0 }}
</p> </p>
<p class="mt-1 text-xs text-gray-500"> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
每分钟请求数 每分钟请求数
<span v-if="dashboardData.isHistoricalMetrics" class="text-yellow-600"> <span v-if="dashboardData.isHistoricalMetrics" class="text-yellow-600">
<i class="fas fa-exclamation-circle" /> 历史数据 <i class="fas fa-exclamation-circle" /> 历史数据
@@ -291,14 +324,14 @@
<div class="stat-card"> <div class="stat-card">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<p class="mb-1 text-xs font-semibold text-gray-600 sm:text-sm"> <p class="mb-1 text-xs font-semibold text-gray-600 dark:text-gray-400 sm:text-sm">
实时TPM 实时TPM
<span class="text-xs text-gray-400">({{ dashboardData.metricsWindow }}分钟)</span> <span class="text-xs text-gray-400">({{ dashboardData.metricsWindow }}分钟)</span>
</p> </p>
<p class="text-2xl font-bold text-rose-600 sm:text-3xl"> <p class="text-2xl font-bold text-rose-600 sm:text-3xl">
{{ formatNumber(dashboardData.realtimeTPM || 0) }} {{ formatNumber(dashboardData.realtimeTPM || 0) }}
</p> </p>
<p class="mt-1 text-xs text-gray-500"> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
每分钟Token数 每分钟Token数
<span v-if="dashboardData.isHistoricalMetrics" class="text-yellow-600"> <span v-if="dashboardData.isHistoricalMetrics" class="text-yellow-600">
<i class="fas fa-exclamation-circle" /> 历史数据 <i class="fas fa-exclamation-circle" /> 历史数据
@@ -315,18 +348,22 @@
<!-- 模型消费统计 --> <!-- 模型消费统计 -->
<div class="mb-8"> <div class="mb-8">
<div class="mb-4 flex flex-col gap-4 sm:mb-6"> <div class="mb-4 flex flex-col gap-4 sm:mb-6">
<h3 class="text-lg font-bold text-gray-900 sm:text-xl">模型使用分布与Token使用趋势</h3> <h3 class="text-lg font-bold text-gray-900 dark:text-gray-100 sm:text-xl">
模型使用分布与Token使用趋势
</h3>
<div class="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-end"> <div class="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-end">
<!-- 快捷日期选择 --> <!-- 快捷日期选择 -->
<div class="flex flex-shrink-0 gap-1 overflow-x-auto rounded-lg bg-gray-100 p-1"> <div
class="flex flex-shrink-0 gap-1 overflow-x-auto rounded-lg bg-gray-100 p-1 dark:bg-gray-700"
>
<button <button
v-for="option in dateFilter.presetOptions" v-for="option in dateFilter.presetOptions"
:key="option.value" :key="option.value"
:class="[ :class="[
'rounded-md px-3 py-1 text-sm font-medium transition-colors', 'rounded-md px-3 py-1 text-sm font-medium transition-colors',
dateFilter.preset === option.value && dateFilter.type === 'preset' dateFilter.preset === option.value && dateFilter.type === 'preset'
? 'bg-white text-blue-600 shadow-sm' ? 'bg-white text-blue-600 shadow-sm dark:bg-gray-800'
: 'text-gray-600 hover:text-gray-900' : 'text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-gray-100'
]" ]"
@click="setDateFilterPreset(option.value)" @click="setDateFilterPreset(option.value)"
> >
@@ -335,13 +372,13 @@
</div> </div>
<!-- 粒度切换按钮 --> <!-- 粒度切换按钮 -->
<div class="flex gap-1 rounded-lg bg-gray-100 p-1"> <div class="flex gap-1 rounded-lg bg-gray-100 p-1 dark:bg-gray-700">
<button <button
:class="[ :class="[
'rounded-md px-3 py-1 text-sm font-medium transition-colors', 'rounded-md px-3 py-1 text-sm font-medium transition-colors',
trendGranularity === 'day' trendGranularity === 'day'
? 'bg-white text-blue-600 shadow-sm' ? 'bg-white text-blue-600 shadow-sm dark:bg-gray-800'
: 'text-gray-600 hover:text-gray-900' : 'text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-gray-100'
]" ]"
@click="setTrendGranularity('day')" @click="setTrendGranularity('day')"
> >
@@ -351,8 +388,8 @@
:class="[ :class="[
'rounded-md px-3 py-1 text-sm font-medium transition-colors', 'rounded-md px-3 py-1 text-sm font-medium transition-colors',
trendGranularity === 'hour' trendGranularity === 'hour'
? 'bg-white text-blue-600 shadow-sm' ? 'bg-white text-blue-600 shadow-sm dark:bg-gray-800'
: 'text-gray-600 hover:text-gray-900' : 'text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-gray-100'
]" ]"
@click="setTrendGranularity('hour')" @click="setTrendGranularity('hour')"
> >
@@ -385,17 +422,17 @@
<!-- 刷新控制 --> <!-- 刷新控制 -->
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<!-- 自动刷新控制 --> <!-- 自动刷新控制 -->
<div class="flex items-center rounded-lg bg-gray-100 px-3 py-1"> <div class="flex items-center rounded-lg bg-gray-100 px-3 py-1 dark:bg-gray-700">
<label class="relative inline-flex cursor-pointer items-center"> <label class="relative inline-flex cursor-pointer items-center">
<input v-model="autoRefreshEnabled" class="peer sr-only" type="checkbox" /> <input v-model="autoRefreshEnabled" class="peer sr-only" type="checkbox" />
<!-- 更小的开关 --> <!-- 更小的开关 -->
<div <div
class="peer relative h-5 w-9 rounded-full bg-gray-300 transition-all duration-200 after:absolute after:left-[2px] after:top-0.5 after:h-4 after:w-4 after:rounded-full after:bg-white after:shadow-sm after:transition-transform after:duration-200 after:content-[''] peer-checked:bg-blue-500 peer-checked:after:translate-x-4 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-blue-300" class="peer relative h-5 w-9 rounded-full bg-gray-300 transition-all duration-200 after:absolute after:left-[2px] after:top-0.5 after:h-4 after:w-4 after:rounded-full after:bg-white after:shadow-sm after:transition-transform after:duration-200 after:content-[''] peer-checked:bg-blue-500 peer-checked:after:translate-x-4 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-blue-300 dark:bg-gray-600 dark:after:bg-gray-300 dark:peer-focus:ring-blue-600"
/> />
<span <span
class="ml-2.5 flex select-none items-center gap-1 text-sm font-medium text-gray-600" class="ml-2.5 flex select-none items-center gap-1 text-sm font-medium text-gray-600 dark:text-gray-300"
> >
<i class="fas fa-redo-alt text-xs text-gray-500" /> <i class="fas fa-redo-alt text-xs text-gray-500 dark:text-gray-400" />
<span>自动刷新</span> <span>自动刷新</span>
<span <span
v-if="autoRefreshEnabled" v-if="autoRefreshEnabled"
@@ -410,7 +447,7 @@
<!-- 刷新按钮 --> <!-- 刷新按钮 -->
<button <button
class="flex items-center gap-1 rounded-md border border-gray-300 bg-white px-3 py-1 text-sm font-medium text-blue-600 shadow-sm transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 sm:gap-2" class="flex items-center gap-1 rounded-md border border-gray-300 bg-white px-3 py-1 text-sm font-medium text-blue-600 shadow-sm transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:hover:bg-gray-700 sm:gap-2"
:disabled="isRefreshing" :disabled="isRefreshing"
title="立即刷新数据" title="立即刷新数据"
@click="refreshAllData()" @click="refreshAllData()"
@@ -425,7 +462,9 @@
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2"> <div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<!-- 饼图 --> <!-- 饼图 -->
<div class="card p-4 sm:p-6"> <div class="card p-4 sm:p-6">
<h4 class="mb-4 text-base font-semibold text-gray-800 sm:text-lg">Token使用分布</h4> <h4 class="mb-4 text-base font-semibold text-gray-800 dark:text-gray-200 sm:text-lg">
Token使用分布
</h4>
<div class="relative" style="height: 250px"> <div class="relative" style="height: 250px">
<canvas ref="modelUsageChart" /> <canvas ref="modelUsageChart" />
</div> </div>
@@ -433,48 +472,62 @@
<!-- 详细数据表格 --> <!-- 详细数据表格 -->
<div class="card p-4 sm:p-6"> <div class="card p-4 sm:p-6">
<h4 class="mb-4 text-base font-semibold text-gray-800 sm:text-lg">详细统计数据</h4> <h4 class="mb-4 text-base font-semibold text-gray-800 dark:text-gray-200 sm:text-lg">
详细统计数据
</h4>
<div v-if="dashboardModelStats.length === 0" class="py-8 text-center"> <div v-if="dashboardModelStats.length === 0" class="py-8 text-center">
<p class="text-sm text-gray-500 sm:text-base">暂无模型使用数据</p> <p class="text-sm text-gray-500 sm:text-base">暂无模型使用数据</p>
</div> </div>
<div v-else class="max-h-[250px] overflow-auto sm:max-h-[300px]"> <div v-else class="max-h-[250px] overflow-auto sm:max-h-[300px]">
<table class="min-w-full"> <table class="min-w-full">
<thead class="sticky top-0 bg-gray-50"> <thead class="sticky top-0 bg-gray-50 dark:bg-gray-700">
<tr> <tr>
<th class="px-2 py-2 text-left text-xs font-medium text-gray-700 sm:px-4"> <th
class="px-2 py-2 text-left text-xs font-medium text-gray-700 dark:text-gray-300 sm:px-4"
>
模型 模型
</th> </th>
<th <th
class="hidden px-2 py-2 text-right text-xs font-medium text-gray-700 sm:table-cell sm:px-4" class="hidden px-2 py-2 text-right text-xs font-medium text-gray-700 dark:text-gray-300 sm:table-cell sm:px-4"
> >
请求数 请求数
</th> </th>
<th class="px-2 py-2 text-right text-xs font-medium text-gray-700 sm:px-4"> <th
class="px-2 py-2 text-right text-xs font-medium text-gray-700 dark:text-gray-300 sm:px-4"
>
总Token 总Token
</th> </th>
<th class="px-2 py-2 text-right text-xs font-medium text-gray-700 sm:px-4"> <th
class="px-2 py-2 text-right text-xs font-medium text-gray-700 dark:text-gray-300 sm:px-4"
>
费用 费用
</th> </th>
<th <th
class="hidden px-2 py-2 text-right text-xs font-medium text-gray-700 sm:table-cell sm:px-4" class="hidden px-2 py-2 text-right text-xs font-medium text-gray-700 dark:text-gray-300 sm:table-cell sm:px-4"
> >
占比 占比
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-200"> <tbody class="divide-y divide-gray-200 dark:divide-gray-600">
<tr v-for="stat in dashboardModelStats" :key="stat.model" class="hover:bg-gray-50"> <tr
<td class="px-2 py-2 text-xs text-gray-900 sm:px-4 sm:text-sm"> v-for="stat in dashboardModelStats"
:key="stat.model"
class="hover:bg-gray-50 dark:hover:bg-gray-700"
>
<td class="px-2 py-2 text-xs text-gray-900 dark:text-gray-100 sm:px-4 sm:text-sm">
<span class="block max-w-[100px] truncate sm:max-w-none" :title="stat.model"> <span class="block max-w-[100px] truncate sm:max-w-none" :title="stat.model">
{{ stat.model }} {{ stat.model }}
</span> </span>
</td> </td>
<td <td
class="hidden px-2 py-2 text-right text-xs text-gray-600 sm:table-cell sm:px-4 sm:text-sm" class="hidden px-2 py-2 text-right text-xs text-gray-600 dark:text-gray-400 sm:table-cell sm:px-4 sm:text-sm"
> >
{{ formatNumber(stat.requests) }} {{ formatNumber(stat.requests) }}
</td> </td>
<td class="px-2 py-2 text-right text-xs text-gray-600 sm:px-4 sm:text-sm"> <td
class="px-2 py-2 text-right text-xs text-gray-600 dark:text-gray-400 sm:px-4 sm:text-sm"
>
{{ formatNumber(stat.allTokens) }} {{ formatNumber(stat.allTokens) }}
</td> </td>
<td <td
@@ -486,7 +539,7 @@
class="hidden px-2 py-2 text-right text-xs font-medium sm:table-cell sm:px-4 sm:text-sm" class="hidden px-2 py-2 text-right text-xs font-medium sm:table-cell sm:px-4 sm:text-sm"
> >
<span <span
class="inline-flex items-center rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-800" class="inline-flex items-center rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-800 dark:bg-blue-900/30 dark:text-blue-300"
> >
{{ calculatePercentage(stat.allTokens, dashboardModelStats) }}% {{ calculatePercentage(stat.allTokens, dashboardModelStats) }}%
</span> </span>
@@ -512,15 +565,17 @@
<div class="mb-4 sm:mb-6 md:mb-8"> <div class="mb-4 sm:mb-6 md:mb-8">
<div class="card p-4 sm:p-6"> <div class="card p-4 sm:p-6">
<div class="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div class="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h3 class="text-base font-semibold text-gray-900 sm:text-lg">API Keys 使用趋势</h3> <h3 class="text-base font-semibold text-gray-900 dark:text-gray-100 sm:text-lg">
API Keys 使用趋势
</h3>
<!-- 维度切换按钮 --> <!-- 维度切换按钮 -->
<div class="flex gap-1 rounded-lg bg-gray-100 p-1"> <div class="flex gap-1 rounded-lg bg-gray-100 p-1 dark:bg-gray-700">
<button <button
:class="[ :class="[
'rounded-md px-2 py-1 text-xs font-medium transition-colors sm:px-3 sm:text-sm', 'rounded-md px-2 py-1 text-xs font-medium transition-colors sm:px-3 sm:text-sm',
apiKeysTrendMetric === 'requests' apiKeysTrendMetric === 'requests'
? 'bg-white text-blue-600 shadow-sm' ? 'bg-white text-blue-600 shadow-sm dark:bg-gray-800'
: 'text-gray-600 hover:text-gray-900' : 'text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-gray-100'
]" ]"
@click="((apiKeysTrendMetric = 'requests'), updateApiKeysUsageTrendChart())" @click="((apiKeysTrendMetric = 'requests'), updateApiKeysUsageTrendChart())"
> >
@@ -531,8 +586,8 @@
:class="[ :class="[
'rounded-md px-2 py-1 text-xs font-medium transition-colors sm:px-3 sm:text-sm', 'rounded-md px-2 py-1 text-xs font-medium transition-colors sm:px-3 sm:text-sm',
apiKeysTrendMetric === 'tokens' apiKeysTrendMetric === 'tokens'
? 'bg-white text-blue-600 shadow-sm' ? 'bg-white text-blue-600 shadow-sm dark:bg-gray-800'
: 'text-gray-600 hover:text-gray-900' : 'text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-gray-100'
]" ]"
@click="((apiKeysTrendMetric = 'tokens'), updateApiKeysUsageTrendChart())" @click="((apiKeysTrendMetric = 'tokens'), updateApiKeysUsageTrendChart())"
> >
@@ -541,7 +596,7 @@
</button> </button>
</div> </div>
</div> </div>
<div class="mb-4 text-xs text-gray-600 sm:text-sm"> <div class="mb-4 text-xs text-gray-600 dark:text-gray-400 sm:text-sm">
<span v-if="apiKeysTrendData.totalApiKeys > 10"> <span v-if="apiKeysTrendData.totalApiKeys > 10">
共 {{ apiKeysTrendData.totalApiKeys }} 个 API Key显示使用量前 10 个 共 {{ apiKeysTrendData.totalApiKeys }} 个 API Key显示使用量前 10 个
</span> </span>
@@ -556,12 +611,16 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue' import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { useDashboardStore } from '@/stores/dashboard' import { useDashboardStore } from '@/stores/dashboard'
import { useThemeStore } from '@/stores/theme'
import Chart from 'chart.js/auto' import Chart from 'chart.js/auto'
const dashboardStore = useDashboardStore() const dashboardStore = useDashboardStore()
const themeStore = useThemeStore()
const { isDarkMode } = storeToRefs(themeStore)
const { const {
dashboardData, dashboardData,
costsData, costsData,
@@ -607,6 +666,13 @@ const isRefreshing = ref(false)
// return `${refreshCountdown.value}秒后刷新` // return `${refreshCountdown.value}秒后刷新`
// }) // })
// 图表颜色配置(根据主题动态调整)
const chartColors = computed(() => ({
text: isDarkMode.value ? '#e5e7eb' : '#374151',
grid: isDarkMode.value ? 'rgba(75, 85, 99, 0.3)' : 'rgba(0, 0, 0, 0.1)',
legend: isDarkMode.value ? '#e5e7eb' : '#374151'
}))
// 格式化数字 // 格式化数字
function formatNumber(num) { function formatNumber(num) {
if (num >= 1000000) { if (num >= 1000000) {
@@ -670,7 +736,8 @@ function createModelUsageChart() {
usePointStyle: true, usePointStyle: true,
font: { font: {
size: 12 size: 12
} },
color: chartColors.value.legend
} }
}, },
tooltip: { tooltip: {
@@ -800,10 +867,14 @@ function createUsageTrendChart() {
font: { font: {
size: 16, size: 16,
weight: 'bold' weight: 'bold'
} },
color: chartColors.value.text
}, },
legend: { legend: {
position: 'top' position: 'top',
labels: {
color: chartColors.value.legend
}
}, },
tooltip: { tooltip: {
mode: 'index', mode: 'index',
@@ -858,7 +929,14 @@ function createUsageTrendChart() {
display: true, display: true,
title: { title: {
display: true, display: true,
text: trendGranularity.value === 'hour' ? '时间' : '日期' text: trendGranularity === 'hour' ? '时间' : '日期',
color: chartColors.value.text
},
ticks: {
color: chartColors.value.text
},
grid: {
color: chartColors.value.grid
} }
}, },
y: { y: {
@@ -867,12 +945,17 @@ function createUsageTrendChart() {
position: 'left', position: 'left',
title: { title: {
display: true, display: true,
text: 'Token数量' text: 'Token数量',
color: chartColors.value.text
}, },
ticks: { ticks: {
callback: function (value) { callback: function (value) {
return formatNumber(value) return formatNumber(value)
} },
color: chartColors.value.text
},
grid: {
color: chartColors.value.grid
} }
}, },
y1: { y1: {
@@ -881,7 +964,8 @@ function createUsageTrendChart() {
position: 'right', position: 'right',
title: { title: {
display: true, display: true,
text: '请求数' text: '请求数',
color: chartColors.value.text
}, },
grid: { grid: {
drawOnChartArea: false drawOnChartArea: false
@@ -889,7 +973,8 @@ function createUsageTrendChart() {
ticks: { ticks: {
callback: function (value) { callback: function (value) {
return value.toLocaleString() return value.toLocaleString()
} },
color: chartColors.value.text
} }
}, },
y2: { y2: {
@@ -998,7 +1083,8 @@ function createApiKeysUsageTrendChart() {
usePointStyle: true, usePointStyle: true,
font: { font: {
size: 12 size: 12
} },
color: chartColors.value.legend
} }
}, },
tooltip: { tooltip: {
@@ -1062,19 +1148,31 @@ function createApiKeysUsageTrendChart() {
display: true, display: true,
title: { title: {
display: true, display: true,
text: trendGranularity.value === 'hour' ? '时间' : '日期' text: trendGranularity === 'hour' ? '时间' : '日期',
color: chartColors.value.text
},
ticks: {
color: chartColors.value.text
},
grid: {
color: chartColors.value.grid
} }
}, },
y: { y: {
beginAtZero: true, beginAtZero: true,
title: { title: {
display: true, display: true,
text: apiKeysTrendMetric.value === 'tokens' ? 'Token 数量' : '请求次数' text: apiKeysTrendMetric.value === 'tokens' ? 'Token 数量' : '请求次数',
color: chartColors.value.text
}, },
ticks: { ticks: {
callback: function (value) { callback: function (value) {
return formatNumber(value) return formatNumber(value)
} },
color: chartColors.value.text
},
grid: {
color: chartColors.value.grid
} }
} }
} }
@@ -1179,6 +1277,15 @@ watch(autoRefreshEnabled, (newVal) => {
} }
}) })
// 监听主题变化,重新创建图表
watch(isDarkMode, () => {
nextTick(() => {
createModelUsageChart()
createUsageTrendChart()
createApiKeysUsageTrendChart()
})
})
// 初始化 // 初始化
onMounted(async () => { onMounted(async () => {
// 加载所有数据 // 加载所有数据
@@ -1208,19 +1315,8 @@ onUnmounted(() => {
</script> </script>
<style scoped> <style scoped>
/* 自定义日期选择器样式 */ /* 日期选择器基本样式调整 - 让Element Plus官方暗黑模式生效 */
.custom-date-picker :deep(.el-input__inner) { .custom-date-picker {
@apply border-gray-300 bg-white focus:border-blue-500 focus:ring-blue-500;
font-size: 13px;
padding: 0 10px;
}
.custom-date-picker :deep(.el-range-separator) {
@apply text-gray-500;
padding: 0 2px;
}
.custom-date-picker :deep(.el-range-input) {
font-size: 13px; font-size: 13px;
} }

View File

@@ -1,5 +1,10 @@
<template> <template>
<div class="flex min-h-screen items-center justify-center p-4 sm:p-6"> <div class="flex min-h-screen items-center justify-center p-4 sm:p-6">
<!-- 主题切换按钮 - 固定在右上角 -->
<div class="fixed right-4 top-4 z-50">
<ThemeToggle mode="dropdown" />
</div>
<div <div
class="glass-strong w-full max-w-md rounded-xl p-6 shadow-2xl sm:rounded-2xl sm:p-8 md:rounded-3xl md:p-10" class="glass-strong w-full max-w-md rounded-xl p-6 shadow-2xl sm:rounded-2xl sm:p-8 md:rounded-3xl md:p-10"
> >
@@ -29,12 +34,14 @@
v-else-if="oemLoading" v-else-if="oemLoading"
class="mx-auto mb-2 h-8 w-48 animate-pulse rounded bg-gray-300/50 sm:h-9 sm:w-64" class="mx-auto mb-2 h-8 w-48 animate-pulse rounded bg-gray-300/50 sm:h-9 sm:w-64"
/> />
<p class="text-base text-gray-600 sm:text-lg">管理后台</p> <p class="text-base text-gray-600 dark:text-gray-400 sm:text-lg">管理后台</p>
</div> </div>
<form class="space-y-4 sm:space-y-6" @submit.prevent="handleLogin"> <form class="space-y-4 sm:space-y-6" @submit.prevent="handleLogin">
<div> <div>
<label class="mb-2 block text-sm font-semibold text-gray-900 sm:mb-3">用户名</label> <label class="mb-2 block text-sm font-semibold text-gray-900 dark:text-gray-100 sm:mb-3"
>用户名</label
>
<input <input
v-model="loginForm.username" v-model="loginForm.username"
class="form-input w-full" class="form-input w-full"
@@ -45,7 +52,9 @@
</div> </div>
<div> <div>
<label class="mb-2 block text-sm font-semibold text-gray-900 sm:mb-3">密码</label> <label class="mb-2 block text-sm font-semibold text-gray-900 dark:text-gray-100 sm:mb-3"
>密码</label
>
<input <input
v-model="loginForm.password" v-model="loginForm.password"
class="form-input w-full" class="form-input w-full"
@@ -68,7 +77,7 @@
<div <div
v-if="authStore.loginError" v-if="authStore.loginError"
class="mt-4 rounded-lg border border-red-500/30 bg-red-500/20 p-3 text-center text-xs text-red-800 backdrop-blur-sm sm:mt-6 sm:rounded-xl sm:p-4 sm:text-sm" class="mt-4 rounded-lg border border-red-500/30 bg-red-500/20 p-3 text-center text-xs text-red-800 backdrop-blur-sm dark:text-red-400 sm:mt-6 sm:rounded-xl sm:p-4 sm:text-sm"
> >
<i class="fas fa-exclamation-triangle mr-2" />{{ authStore.loginError }} <i class="fas fa-exclamation-triangle mr-2" />{{ authStore.loginError }}
</div> </div>
@@ -79,8 +88,11 @@
<script setup> <script setup>
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed } from 'vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useThemeStore } from '@/stores/theme'
import ThemeToggle from '@/components/common/ThemeToggle.vue'
const authStore = useAuthStore() const authStore = useAuthStore()
const themeStore = useThemeStore()
const oemLoading = computed(() => authStore.oemLoading) const oemLoading = computed(() => authStore.oemLoading)
const loginForm = ref({ const loginForm = ref({
@@ -89,6 +101,8 @@ const loginForm = ref({
}) })
onMounted(() => { onMounted(() => {
// 初始化主题
themeStore.initTheme()
// 加载OEM设置 // 加载OEM设置
authStore.loadOemSettings() authStore.loadOemSettings()
}) })

File diff suppressed because it is too large Load Diff

View File

@@ -1,26 +1,28 @@
<template> <template>
<div class="card p-3 sm:p-6"> <div class="card p-3 sm:p-6">
<div class="mb-4 sm:mb-8"> <div class="mb-4 sm:mb-8">
<h3 class="mb-3 flex items-center text-xl font-bold text-gray-900 sm:mb-4 sm:text-2xl"> <h3
class="mb-3 flex items-center text-xl font-bold text-gray-900 dark:text-gray-100 sm:mb-4 sm:text-2xl"
>
<i class="fas fa-graduation-cap mr-2 text-blue-600 sm:mr-3" /> <i class="fas fa-graduation-cap mr-2 text-blue-600 sm:mr-3" />
Claude Code 使用教程 Claude Code 使用教程
</h3> </h3>
<p class="text-sm text-gray-600 sm:text-lg"> <p class="text-sm text-gray-600 dark:text-gray-400 sm:text-lg">
跟着这个教程你可以轻松在自己的电脑上安装并使用 Claude Code 跟着这个教程你可以轻松在自己的电脑上安装并使用 Claude Code
</p> </p>
</div> </div>
<!-- 系统选择标签 --> <!-- 系统选择标签 -->
<div class="mb-4 sm:mb-8"> <div class="mb-4 sm:mb-8">
<div class="flex flex-wrap gap-1 rounded-xl bg-gray-100 p-1 sm:gap-2 sm:p-2"> <div class="flex flex-wrap gap-1 rounded-xl bg-gray-100 p-1 dark:bg-gray-700 sm:gap-2 sm:p-2">
<button <button
v-for="system in tutorialSystems" v-for="system in tutorialSystems"
:key="system.key" :key="system.key"
:class="[ :class="[
'flex flex-1 items-center justify-center gap-1 rounded-lg px-3 py-2 text-xs font-semibold transition-all duration-300 sm:gap-2 sm:px-6 sm:py-3 sm:text-sm', 'flex flex-1 items-center justify-center gap-1 rounded-lg px-3 py-2 text-xs font-semibold transition-all duration-300 sm:gap-2 sm:px-6 sm:py-3 sm:text-sm',
activeTutorialSystem === system.key activeTutorialSystem === system.key
? 'bg-white text-blue-600 shadow-sm' ? 'bg-white text-blue-600 shadow-sm dark:bg-gray-800'
: 'text-gray-600 hover:bg-white/50 hover:text-gray-900' : 'text-gray-600 hover:bg-white/50 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200'
]" ]"
@click="activeTutorialSystem = system.key" @click="activeTutorialSystem = system.key"
> >
@@ -34,14 +36,16 @@
<div v-if="activeTutorialSystem === 'windows'" class="tutorial-content"> <div v-if="activeTutorialSystem === 'windows'" class="tutorial-content">
<!-- 第一步安装 Node.js --> <!-- 第一步安装 Node.js -->
<div class="mb-4 sm:mb-10 sm:mb-6"> <div class="mb-4 sm:mb-10 sm:mb-6">
<h4 class="mb-3 flex items-center text-lg font-semibold text-gray-800 sm:mb-4 sm:text-xl"> <h4
class="mb-3 flex items-center text-lg font-semibold text-gray-800 dark:text-gray-300 sm:mb-4 sm:text-xl"
>
<span <span
class="mr-2 flex h-6 w-6 items-center justify-center rounded-full bg-blue-500 text-xs font-bold text-white sm:mr-3 sm:h-8 sm:w-8 sm:text-sm" class="mr-2 flex h-6 w-6 items-center justify-center rounded-full bg-blue-500 text-xs font-bold text-white sm:mr-3 sm:h-8 sm:w-8 sm:text-sm"
>1</span >1</span
> >
安装 Node.js 环境 安装 Node.js 环境
</h4> </h4>
<p class="mb-4 text-sm text-gray-600 sm:mb-4 sm:mb-6 sm:text-base"> <p class="mb-4 text-sm text-gray-600 dark:text-gray-400 sm:mb-4 sm:mb-6 sm:text-base">
Claude Code 需要 Node.js 环境才能运行 Claude Code 需要 Node.js 环境才能运行
</p> </p>
@@ -49,34 +53,42 @@
class="mb-4 rounded-xl border border-blue-100 bg-gradient-to-r from-blue-50 to-indigo-50 p-4 sm:mb-4 sm:mb-6 sm:p-6" class="mb-4 rounded-xl border border-blue-100 bg-gradient-to-r from-blue-50 to-indigo-50 p-4 sm:mb-4 sm:mb-6 sm:p-6"
> >
<h5 <h5
class="mb-2 flex items-center text-base font-semibold text-gray-800 sm:mb-3 sm:text-lg" class="mb-2 flex items-center text-base font-semibold text-gray-800 dark:text-gray-600 sm:mb-3 sm:text-lg"
> >
<i class="fab fa-windows mr-2 text-blue-600" /> <i class="fab fa-windows mr-2 text-blue-600" />
Windows 安装方法 Windows 安装方法
</h5> </h5>
<div class="mb-3 sm:mb-4"> <div class="mb-3 sm:mb-4">
<p class="mb-2 text-sm text-gray-700 sm:mb-3 sm:text-base">方法一官网下载推荐</p> <p class="mb-2 text-sm text-gray-700 dark:text-gray-600 sm:mb-3 sm:text-base">
方法一官网下载推荐
</p>
<ol <ol
class="ml-2 list-inside list-decimal space-y-1 text-xs text-gray-600 sm:ml-4 sm:space-y-2 sm:text-sm" class="ml-2 list-inside list-decimal space-y-1 text-xs text-gray-600 dark:text-gray-600 sm:ml-4 sm:space-y-2 sm:text-sm"
> >
<li> <li>
打开浏览器访问 打开浏览器访问
<code class="rounded bg-gray-100 px-1 py-1 text-xs sm:px-2 sm:text-sm" <code
class="rounded bg-gray-100 px-1 py-1 text-xs dark:bg-gray-700 sm:px-2 sm:text-sm"
>https://nodejs.org/</code >https://nodejs.org/</code
> >
</li> </li>
<li>点击 "LTS" 版本进行下载推荐长期支持版本</li> <li>点击 "LTS" 版本进行下载推荐长期支持版本</li>
<li> <li>
下载完成后双击 下载完成后双击
<code class="rounded bg-gray-100 px-1 py-1 text-xs sm:px-2 sm:text-sm">.msi</code> <code
class="rounded bg-gray-100 px-1 py-1 text-xs dark:bg-gray-700 sm:px-2 sm:text-sm"
>.msi</code
>
文件 文件
</li> </li>
<li>按照安装向导完成安装保持默认设置即可</li> <li>按照安装向导完成安装保持默认设置即可</li>
</ol> </ol>
</div> </div>
<div class="mb-3 sm:mb-4"> <div class="mb-3 sm:mb-4">
<p class="mb-2 text-sm text-gray-700 sm:mb-3 sm:text-base">方法二使用包管理器</p> <p class="mb-2 text-sm text-gray-700 dark:text-gray-600 sm:mb-3 sm:text-base">
<p class="mb-2 text-xs text-gray-600 sm:text-sm"> 方法二使用包管理器
</p>
<p class="mb-2 text-xs text-gray-600 dark:text-gray-400 sm:text-sm">
如果你安装了 Chocolatey Scoop可以使用命令行安装 如果你安装了 Chocolatey Scoop可以使用命令行安装
</p> </p>
<div <div
@@ -116,7 +128,9 @@
<!-- 第二步安装 Claude Code --> <!-- 第二步安装 Claude Code -->
<div class="mb-4 sm:mb-10 sm:mb-6"> <div class="mb-4 sm:mb-10 sm:mb-6">
<h4 class="mb-3 flex items-center text-lg font-semibold text-gray-800 sm:mb-4 sm:text-xl"> <h4
class="mb-3 flex items-center text-lg font-semibold text-gray-800 dark:text-gray-300 sm:mb-4 sm:text-xl"
>
<span <span
class="mr-2 flex h-6 w-6 items-center justify-center rounded-full bg-green-500 text-xs font-bold text-white sm:mr-3 sm:h-8 sm:w-8 sm:text-sm" class="mr-2 flex h-6 w-6 items-center justify-center rounded-full bg-green-500 text-xs font-bold text-white sm:mr-3 sm:h-8 sm:w-8 sm:text-sm"
>2</span >2</span
@@ -128,12 +142,12 @@
class="mb-4 rounded-xl border border-green-100 bg-gradient-to-r from-green-50 to-emerald-50 p-4 sm:mb-6 sm:p-6" class="mb-4 rounded-xl border border-green-100 bg-gradient-to-r from-green-50 to-emerald-50 p-4 sm:mb-6 sm:p-6"
> >
<h5 <h5
class="mb-2 flex items-center text-base font-semibold text-gray-800 sm:mb-3 sm:text-lg" class="mb-2 flex items-center text-base font-semibold text-gray-800 dark:text-gray-600 sm:mb-3 sm:text-lg"
> >
<i class="fas fa-download mr-2 text-green-600" /> <i class="fas fa-download mr-2 text-green-600" />
安装 Claude Code 安装 Claude Code
</h5> </h5>
<p class="mb-3 text-sm text-gray-700 sm:mb-4 sm:text-base"> <p class="mb-3 text-sm text-gray-700 dark:text-gray-300 sm:mb-4 sm:text-base">
打开 PowerShell CMD运行以下命令 打开 PowerShell CMD运行以下命令
</p> </p>
<div <div
@@ -144,7 +158,7 @@
npm install -g @anthropic-ai/claude-code npm install -g @anthropic-ai/claude-code
</div> </div>
</div> </div>
<p class="text-sm text-gray-600"> <p class="text-sm text-gray-600 dark:text-gray-400">
这个命令会从 npm 官方仓库下载并安装最新版本的 Claude Code 这个命令会从 npm 官方仓库下载并安装最新版本的 Claude Code
</p> </p>
@@ -159,7 +173,7 @@
<!-- 验证安装 --> <!-- 验证安装 -->
<div class="rounded-lg border border-green-200 bg-green-50 p-3 sm:p-4"> <div class="rounded-lg border border-green-200 bg-green-50 p-3 sm:p-4">
<h6 class="mb-2 font-medium text-green-800">验证 Claude Code 安装</h6> <h6 class="mb-2 font-medium text-green-800 dark:text-green-300">验证 Claude Code 安装</h6>
<p class="mb-3 text-sm text-green-700">安装完成后输入以下命令检查是否安装成功</p> <p class="mb-3 text-sm text-green-700">安装完成后输入以下命令检查是否安装成功</p>
<div <div
class="overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm" class="overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
@@ -174,7 +188,9 @@
<!-- 第三步设置环境变量 --> <!-- 第三步设置环境变量 -->
<div class="mb-6 sm:mb-10"> <div class="mb-6 sm:mb-10">
<h4 class="mb-3 flex items-center text-lg font-semibold text-gray-800 sm:mb-4 sm:text-xl"> <h4
class="mb-3 flex items-center text-lg font-semibold text-gray-800 dark:text-gray-300 sm:mb-4 sm:text-xl"
>
<span <span
class="mr-2 flex h-6 w-6 items-center justify-center rounded-full bg-purple-500 text-xs font-bold text-white sm:mr-3 sm:h-8 sm:w-8 sm:text-sm" class="mr-2 flex h-6 w-6 items-center justify-center rounded-full bg-purple-500 text-xs font-bold text-white sm:mr-3 sm:h-8 sm:w-8 sm:text-sm"
>3</span >3</span
@@ -186,18 +202,20 @@
class="mb-4 rounded-xl border border-purple-100 bg-gradient-to-r from-purple-50 to-pink-50 p-4 sm:mb-6 sm:p-6" class="mb-4 rounded-xl border border-purple-100 bg-gradient-to-r from-purple-50 to-pink-50 p-4 sm:mb-6 sm:p-6"
> >
<h5 <h5
class="mb-2 flex items-center text-base font-semibold text-gray-800 sm:mb-3 sm:text-lg" class="mb-2 flex items-center text-base font-semibold text-gray-800 dark:text-gray-600 sm:mb-3 sm:text-lg"
> >
<i class="fas fa-cog mr-2 text-purple-600" /> <i class="fas fa-cog mr-2 text-purple-600" />
配置 Claude Code 环境变量 配置 Claude Code 环境变量
</h5> </h5>
<p class="mb-3 text-sm text-gray-700 sm:mb-4 sm:text-base"> <p class="mb-3 text-sm text-gray-700 dark:text-gray-300 sm:mb-4 sm:text-base">
为了让 Claude Code 连接到你的中转服务需要设置两个环境变量 为了让 Claude Code 连接到你的中转服务需要设置两个环境变量
</p> </p>
<div class="space-y-4"> <div class="space-y-4">
<div class="rounded-lg border border-purple-200 bg-white p-3 sm:p-4"> <div
<h6 class="mb-2 text-sm font-medium text-gray-800 sm:text-base"> class="rounded-lg border border-purple-200 bg-white p-3 dark:border-purple-700 dark:bg-gray-800 sm:p-4"
>
<h6 class="mb-2 text-sm font-medium text-gray-800 dark:text-gray-600 sm:text-base">
方法一PowerShell 临时设置当前会话 方法一PowerShell 临时设置当前会话
</h6> </h6>
<p class="mb-3 text-sm text-gray-600"> PowerShell 中运行以下命令</p> <p class="mb-3 text-sm text-gray-600"> PowerShell 中运行以下命令</p>
@@ -216,8 +234,10 @@
</p> </p>
</div> </div>
<div class="rounded-lg border border-purple-200 bg-white p-3 sm:p-4"> <div
<h6 class="mb-2 text-sm font-medium text-gray-800 sm:text-base"> class="rounded-lg border border-purple-200 bg-white p-3 dark:border-purple-700 dark:bg-gray-800 sm:p-4"
>
<h6 class="mb-2 text-sm font-medium text-gray-800 dark:text-gray-600 sm:text-base">
方法二PowerShell 永久设置用户级 方法二PowerShell 永久设置用户级
</h6> </h6>
<p class="mb-3 text-sm text-gray-600"> <p class="mb-3 text-sm text-gray-600">
@@ -260,14 +280,14 @@
<!-- 验证环境变量设置 --> <!-- 验证环境变量设置 -->
<div class="mt-6 rounded-lg border border-blue-200 bg-blue-50 p-3 sm:p-4"> <div class="mt-6 rounded-lg border border-blue-200 bg-blue-50 p-3 sm:p-4">
<h6 class="mb-2 font-medium text-blue-800">验证环境变量设置</h6> <h6 class="mb-2 font-medium text-blue-800 dark:text-blue-300">验证环境变量设置</h6>
<p class="mb-3 text-sm text-blue-700"> <p class="mb-3 text-sm text-blue-700">
设置完环境变量后可以通过以下命令验证是否设置成功 设置完环境变量后可以通过以下命令验证是否设置成功
</p> </p>
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<h6 class="mb-2 text-sm font-medium text-gray-800 sm:text-base"> <h6 class="mb-2 text-sm font-medium text-gray-800 dark:text-gray-600 sm:text-base">
PowerShell 中验证 PowerShell 中验证
</h6> </h6>
<div <div
@@ -279,7 +299,9 @@
</div> </div>
<div> <div>
<h6 class="mb-2 text-sm font-medium text-gray-800 sm:text-base"> CMD 中验证</h6> <h6 class="mb-2 text-sm font-medium text-gray-800 dark:text-gray-600 sm:text-base">
CMD 中验证
</h6>
<div <div
class="space-y-1 overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm" class="space-y-1 overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
> >
@@ -293,7 +315,7 @@
<p class="text-sm text-blue-700"> <p class="text-sm text-blue-700">
<strong>预期输出示例</strong> <strong>预期输出示例</strong>
</p> </p>
<div class="rounded bg-gray-100 p-2 font-mono text-sm"> <div class="rounded bg-gray-100 p-2 font-mono text-sm dark:bg-gray-700">
<div>{{ currentBaseUrl }}</div> <div>{{ currentBaseUrl }}</div>
<div>cr_xxxxxxxxxxxxxxxxxx</div> <div>cr_xxxxxxxxxxxxxxxxxx</div>
</div> </div>
@@ -306,18 +328,18 @@
<!-- Gemini CLI 环境变量设置 --> <!-- Gemini CLI 环境变量设置 -->
<div class="mt-8"> <div class="mt-8">
<h5 <h5
class="mb-2 flex items-center text-base font-semibold text-gray-800 sm:mb-3 sm:text-lg" class="mb-2 flex items-center text-base font-semibold text-gray-800 dark:text-gray-600 sm:mb-3 sm:text-lg"
> >
<i class="fas fa-robot mr-2 text-green-600" /> <i class="fas fa-robot mr-2 text-green-600" />
配置 Gemini CLI 环境变量 配置 Gemini CLI 环境变量
</h5> </h5>
<p class="mb-3 text-sm text-gray-700 sm:mb-4 sm:text-base"> <p class="mb-3 text-sm text-gray-700 dark:text-gray-300 sm:mb-4 sm:text-base">
如果你使用 Gemini CLI需要设置以下环境变量 如果你使用 Gemini CLI需要设置以下环境变量
</p> </p>
<div class="space-y-4"> <div class="space-y-4">
<div class="rounded-lg border border-green-200 bg-white p-3 sm:p-4"> <div class="rounded-lg border border-green-200 bg-white p-3 sm:p-4">
<h6 class="mb-2 text-sm font-medium text-gray-800 sm:text-base"> <h6 class="mb-2 text-sm font-medium text-gray-800 dark:text-gray-600 sm:text-base">
PowerShell 设置方法 PowerShell 设置方法
</h6> </h6>
<p class="mb-3 text-sm text-gray-600"> PowerShell 中运行以下命令</p> <p class="mb-3 text-sm text-gray-600"> PowerShell 中运行以下命令</p>
@@ -340,7 +362,7 @@
</div> </div>
<div class="rounded-lg border border-green-200 bg-white p-3 sm:p-4"> <div class="rounded-lg border border-green-200 bg-white p-3 sm:p-4">
<h6 class="mb-2 text-sm font-medium text-gray-800 sm:text-base"> <h6 class="mb-2 text-sm font-medium text-gray-800 dark:text-gray-600 sm:text-base">
PowerShell 永久设置用户级 PowerShell 永久设置用户级
</h6> </h6>
<p class="mb-3 text-sm text-gray-600"> PowerShell 中运行以下命令</p> <p class="mb-3 text-sm text-gray-600"> PowerShell 中运行以下命令</p>
@@ -368,7 +390,9 @@
</div> </div>
<div class="rounded-lg border border-green-200 bg-green-50 p-3 sm:p-4"> <div class="rounded-lg border border-green-200 bg-green-50 p-3 sm:p-4">
<h6 class="mb-2 font-medium text-green-800">验证 Gemini CLI 环境变量</h6> <h6 class="mb-2 font-medium text-green-800 dark:text-green-300">
验证 Gemini CLI 环境变量
</h6>
<p class="mb-3 text-sm text-green-700"> PowerShell 中验证</p> <p class="mb-3 text-sm text-green-700"> PowerShell 中验证</p>
<div <div
class="space-y-1 overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm" class="space-y-1 overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
@@ -386,18 +410,18 @@
<!-- Codex 环境变量设置 --> <!-- Codex 环境变量设置 -->
<div class="mt-8"> <div class="mt-8">
<h5 <h5
class="mb-2 flex items-center text-base font-semibold text-gray-800 sm:mb-3 sm:text-lg" class="mb-2 flex items-center text-base font-semibold text-gray-800 dark:text-gray-600 sm:mb-3 sm:text-lg"
> >
<i class="fas fa-code mr-2 text-indigo-600" /> <i class="fas fa-code mr-2 text-indigo-600" />
配置 Codex 环境变量 配置 Codex 环境变量
</h5> </h5>
<p class="mb-3 text-sm text-gray-700 sm:mb-4 sm:text-base"> <p class="mb-3 text-sm text-gray-700 dark:text-gray-300 sm:mb-4 sm:text-base">
如果你使用支持 OpenAI API 的工具 Codex需要设置以下环境变量 如果你使用支持 OpenAI API 的工具 Codex需要设置以下环境变量
</p> </p>
<div class="space-y-4"> <div class="space-y-4">
<div class="rounded-lg border border-indigo-200 bg-white p-3 sm:p-4"> <div class="rounded-lg border border-indigo-200 bg-white p-3 sm:p-4">
<h6 class="mb-2 text-sm font-medium text-gray-800 sm:text-base"> <h6 class="mb-2 text-sm font-medium text-gray-800 dark:text-gray-600 sm:text-base">
PowerShell 设置方法 PowerShell 设置方法
</h6> </h6>
<p class="mb-3 text-sm text-gray-600"> PowerShell 中运行以下命令</p> <p class="mb-3 text-sm text-gray-600"> PowerShell 中运行以下命令</p>
@@ -417,7 +441,7 @@
</div> </div>
<div class="rounded-lg border border-indigo-200 bg-white p-3 sm:p-4"> <div class="rounded-lg border border-indigo-200 bg-white p-3 sm:p-4">
<h6 class="mb-2 text-sm font-medium text-gray-800 sm:text-base"> <h6 class="mb-2 text-sm font-medium text-gray-800 dark:text-gray-600 sm:text-base">
PowerShell 永久设置用户级 PowerShell 永久设置用户级
</h6> </h6>
<p class="mb-3 text-sm text-gray-600"> PowerShell 中运行以下命令</p> <p class="mb-3 text-sm text-gray-600"> PowerShell 中运行以下命令</p>
@@ -456,7 +480,9 @@
<!-- 第四步开始使用 --> <!-- 第四步开始使用 -->
<div class="mb-6 sm:mb-8"> <div class="mb-6 sm:mb-8">
<h4 class="mb-3 flex items-center text-lg font-semibold text-gray-800 sm:mb-4 sm:text-xl"> <h4
class="mb-3 flex items-center text-lg font-semibold text-gray-800 dark:text-gray-300 sm:mb-4 sm:text-xl"
>
<span <span
class="mr-2 flex h-6 w-6 items-center justify-center rounded-full bg-orange-500 text-xs font-bold text-white sm:mr-3 sm:h-8 sm:w-8 sm:text-sm" class="mr-2 flex h-6 w-6 items-center justify-center rounded-full bg-orange-500 text-xs font-bold text-white sm:mr-3 sm:h-8 sm:w-8 sm:text-sm"
>4</span >4</span
@@ -466,13 +492,15 @@
<div <div
class="rounded-xl border border-orange-100 bg-gradient-to-r from-orange-50 to-yellow-50 p-4 sm:p-6" class="rounded-xl border border-orange-100 bg-gradient-to-r from-orange-50 to-yellow-50 p-4 sm:p-6"
> >
<p class="mb-3 text-sm text-gray-700 sm:mb-4 sm:text-base"> <p class="mb-3 text-sm text-gray-700 dark:text-gray-300 sm:mb-4 sm:text-base">
现在你可以开始使用 Claude Code 现在你可以开始使用 Claude Code
</p> </p>
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<h6 class="mb-2 text-sm font-medium text-gray-800 sm:text-base">启动 Claude Code</h6> <h6 class="mb-2 text-sm font-medium text-gray-800 dark:text-gray-600 sm:text-base">
启动 Claude Code
</h6>
<div <div
class="overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm" class="overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
> >
@@ -481,7 +509,9 @@
</div> </div>
<div> <div>
<h6 class="mb-2 text-sm font-medium text-gray-800 sm:text-base">在特定项目中使用</h6> <h6 class="mb-2 text-sm font-medium text-gray-800 dark:text-gray-600 sm:text-base">
在特定项目中使用
</h6>
<div <div
class="overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm" class="overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
> >
@@ -497,7 +527,9 @@
<!-- Windows 故障排除 --> <!-- Windows 故障排除 -->
<div class="mb-8"> <div class="mb-8">
<h4 class="mb-3 flex items-center text-lg font-semibold text-gray-800 sm:mb-4 sm:text-xl"> <h4
class="mb-3 flex items-center text-lg font-semibold text-gray-800 dark:text-gray-300 sm:mb-4 sm:text-xl"
>
<i class="fas fa-wrench mr-2 text-red-600 sm:mr-3" /> <i class="fas fa-wrench mr-2 text-red-600 sm:mr-3" />
Windows 常见问题解决 Windows 常见问题解决
</h4> </h4>
@@ -567,7 +599,9 @@
<div v-else-if="activeTutorialSystem === 'macos'" class="tutorial-content"> <div v-else-if="activeTutorialSystem === 'macos'" class="tutorial-content">
<!-- 第一步安装 Node.js --> <!-- 第一步安装 Node.js -->
<div class="mb-6 sm:mb-10"> <div class="mb-6 sm:mb-10">
<h4 class="mb-3 flex items-center text-lg font-semibold text-gray-800 sm:mb-4 sm:text-xl"> <h4
class="mb-3 flex items-center text-lg font-semibold text-gray-800 dark:text-gray-300 sm:mb-4 sm:text-xl"
>
<span <span
class="mr-2 flex h-6 w-6 items-center justify-center rounded-full bg-blue-500 text-xs font-bold text-white sm:mr-3 sm:h-8 sm:w-8 sm:text-sm" class="mr-2 flex h-6 w-6 items-center justify-center rounded-full bg-blue-500 text-xs font-bold text-white sm:mr-3 sm:h-8 sm:w-8 sm:text-sm"
>1</span >1</span
@@ -580,14 +614,14 @@
class="mb-4 rounded-xl border border-gray-200 bg-gradient-to-r from-gray-50 to-slate-50 p-4 sm:mb-6 sm:p-6" class="mb-4 rounded-xl border border-gray-200 bg-gradient-to-r from-gray-50 to-slate-50 p-4 sm:mb-6 sm:p-6"
> >
<h5 <h5
class="mb-2 flex items-center text-base font-semibold text-gray-800 sm:mb-3 sm:text-lg" class="mb-2 flex items-center text-base font-semibold text-gray-800 dark:text-gray-600 sm:mb-3 sm:text-lg"
> >
<i class="fab fa-apple mr-2 text-gray-700" /> <i class="fab fa-apple mr-2 text-gray-700" />
macOS 安装方法 macOS 安装方法
</h5> </h5>
<div class="mb-4"> <div class="mb-4">
<p class="mb-3 text-gray-700">方法一使用 Homebrew推荐</p> <p class="mb-3 text-gray-700">方法一使用 Homebrew推荐</p>
<p class="mb-2 text-xs text-gray-600 sm:text-sm"> <p class="mb-2 text-xs text-gray-600 dark:text-gray-400 sm:text-sm">
如果你已经安装了 Homebrew使用它安装 Node.js 会更方便 如果你已经安装了 Homebrew使用它安装 Node.js 会更方便
</p> </p>
<div <div
@@ -602,18 +636,22 @@
<div class="mb-4"> <div class="mb-4">
<p class="mb-3 text-gray-700">方法二官网下载</p> <p class="mb-3 text-gray-700">方法二官网下载</p>
<ol <ol
class="ml-2 list-inside list-decimal space-y-1 text-xs text-gray-600 sm:ml-4 sm:space-y-2 sm:text-sm" class="ml-2 list-inside list-decimal space-y-1 text-xs text-gray-600 dark:text-gray-400 sm:ml-4 sm:space-y-2 sm:text-sm"
> >
<li> <li>
访问 访问
<code class="rounded bg-gray-100 px-1 py-1 text-xs sm:px-2 sm:text-sm" <code
class="rounded bg-gray-100 px-1 py-1 text-xs dark:bg-gray-700 sm:px-2 sm:text-sm"
>https://nodejs.org/</code >https://nodejs.org/</code
> >
</li> </li>
<li>下载适合 macOS LTS 版本</li> <li>下载适合 macOS LTS 版本</li>
<li> <li>
打开下载的 打开下载的
<code class="rounded bg-gray-100 px-1 py-1 text-xs sm:px-2 sm:text-sm">.pkg</code> <code
class="rounded bg-gray-100 px-1 py-1 text-xs dark:bg-gray-700 sm:px-2 sm:text-sm"
>.pkg</code
>
文件 文件
</li> </li>
<li>按照安装程序指引完成安装</li> <li>按照安装程序指引完成安装</li>
@@ -634,7 +672,7 @@
<!-- 验证安装 --> <!-- 验证安装 -->
<div class="rounded-lg border border-green-200 bg-green-50 p-3 sm:p-4"> <div class="rounded-lg border border-green-200 bg-green-50 p-3 sm:p-4">
<h6 class="mb-2 font-medium text-green-800">验证安装是否成功</h6> <h6 class="mb-2 font-medium text-green-800 dark:text-green-300">验证安装是否成功</h6>
<p class="mb-3 text-sm text-green-700">安装完成后打开 Terminal输入以下命令</p> <p class="mb-3 text-sm text-green-700">安装完成后打开 Terminal输入以下命令</p>
<div <div
class="overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm" class="overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
@@ -648,7 +686,9 @@
<!-- 第二步安装 Claude Code --> <!-- 第二步安装 Claude Code -->
<div class="mb-6 sm:mb-10"> <div class="mb-6 sm:mb-10">
<h4 class="mb-3 flex items-center text-lg font-semibold text-gray-800 sm:mb-4 sm:text-xl"> <h4
class="mb-3 flex items-center text-lg font-semibold text-gray-800 dark:text-gray-300 sm:mb-4 sm:text-xl"
>
<span <span
class="mr-2 flex h-6 w-6 items-center justify-center rounded-full bg-green-500 text-xs font-bold text-white sm:mr-3 sm:h-8 sm:w-8 sm:text-sm" class="mr-2 flex h-6 w-6 items-center justify-center rounded-full bg-green-500 text-xs font-bold text-white sm:mr-3 sm:h-8 sm:w-8 sm:text-sm"
>2</span >2</span
@@ -660,12 +700,12 @@
class="mb-4 rounded-xl border border-purple-100 bg-gradient-to-r from-purple-50 to-pink-50 p-4 sm:mb-6 sm:p-6" class="mb-4 rounded-xl border border-purple-100 bg-gradient-to-r from-purple-50 to-pink-50 p-4 sm:mb-6 sm:p-6"
> >
<h5 <h5
class="mb-2 flex items-center text-base font-semibold text-gray-800 sm:mb-3 sm:text-lg" class="mb-2 flex items-center text-base font-semibold text-gray-800 dark:text-gray-600 sm:mb-3 sm:text-lg"
> >
<i class="fas fa-download mr-2 text-purple-600" /> <i class="fas fa-download mr-2 text-purple-600" />
安装 Claude Code 安装 Claude Code
</h5> </h5>
<p class="mb-3 text-sm text-gray-700 sm:mb-4 sm:text-base"> <p class="mb-3 text-sm text-gray-700 dark:text-gray-300 sm:mb-4 sm:text-base">
打开 Terminal运行以下命令 打开 Terminal运行以下命令
</p> </p>
<div <div
@@ -688,7 +728,7 @@
<!-- 验证安装 --> <!-- 验证安装 -->
<div class="rounded-lg border border-green-200 bg-green-50 p-3 sm:p-4"> <div class="rounded-lg border border-green-200 bg-green-50 p-3 sm:p-4">
<h6 class="mb-2 font-medium text-green-800">验证 Claude Code 安装</h6> <h6 class="mb-2 font-medium text-green-800 dark:text-green-300">验证 Claude Code 安装</h6>
<p class="mb-3 text-sm text-green-700">安装完成后输入以下命令检查是否安装成功</p> <p class="mb-3 text-sm text-green-700">安装完成后输入以下命令检查是否安装成功</p>
<div <div
class="overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm" class="overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
@@ -703,7 +743,9 @@
<!-- 第三步设置环境变量 --> <!-- 第三步设置环境变量 -->
<div class="mb-6 sm:mb-10"> <div class="mb-6 sm:mb-10">
<h4 class="mb-3 flex items-center text-lg font-semibold text-gray-800 sm:mb-4 sm:text-xl"> <h4
class="mb-3 flex items-center text-lg font-semibold text-gray-800 dark:text-gray-300 sm:mb-4 sm:text-xl"
>
<span <span
class="mr-2 flex h-6 w-6 items-center justify-center rounded-full bg-orange-500 text-xs font-bold text-white sm:mr-3 sm:h-8 sm:w-8 sm:text-sm" class="mr-2 flex h-6 w-6 items-center justify-center rounded-full bg-orange-500 text-xs font-bold text-white sm:mr-3 sm:h-8 sm:w-8 sm:text-sm"
>3</span >3</span
@@ -715,18 +757,18 @@
class="mb-4 rounded-xl border border-orange-100 bg-gradient-to-r from-orange-50 to-yellow-50 p-4 sm:mb-6 sm:p-6" class="mb-4 rounded-xl border border-orange-100 bg-gradient-to-r from-orange-50 to-yellow-50 p-4 sm:mb-6 sm:p-6"
> >
<h5 <h5
class="mb-2 flex items-center text-base font-semibold text-gray-800 sm:mb-3 sm:text-lg" class="mb-2 flex items-center text-base font-semibold text-gray-800 dark:text-gray-600 sm:mb-3 sm:text-lg"
> >
<i class="fas fa-cog mr-2 text-orange-600" /> <i class="fas fa-cog mr-2 text-orange-600" />
配置 Claude Code 环境变量 配置 Claude Code 环境变量
</h5> </h5>
<p class="mb-3 text-sm text-gray-700 sm:mb-4 sm:text-base"> <p class="mb-3 text-sm text-gray-700 dark:text-gray-300 sm:mb-4 sm:text-base">
为了让 Claude Code 连接到你的中转服务需要设置两个环境变量 为了让 Claude Code 连接到你的中转服务需要设置两个环境变量
</p> </p>
<div class="space-y-4"> <div class="space-y-4">
<div class="rounded-lg border border-orange-200 bg-white p-3 sm:p-4"> <div class="rounded-lg border border-orange-200 bg-white p-3 sm:p-4">
<h6 class="mb-2 text-sm font-medium text-gray-800 sm:text-base"> <h6 class="mb-2 text-sm font-medium text-gray-800 dark:text-gray-600 sm:text-base">
方法一临时设置当前会话 方法一临时设置当前会话
</h6> </h6>
<p class="mb-3 text-sm text-gray-600"> Terminal 中运行以下命令</p> <p class="mb-3 text-sm text-gray-600"> Terminal 中运行以下命令</p>
@@ -746,7 +788,9 @@
</div> </div>
<div class="rounded-lg border border-orange-200 bg-white p-3 sm:p-4"> <div class="rounded-lg border border-orange-200 bg-white p-3 sm:p-4">
<h6 class="mb-2 text-sm font-medium text-gray-800 sm:text-base">方法二永久设置</h6> <h6 class="mb-2 text-sm font-medium text-gray-800 dark:text-gray-600 sm:text-base">
方法二永久设置
</h6>
<p class="mb-3 text-sm text-gray-600"> <p class="mb-3 text-sm text-gray-600">
编辑你的 shell 配置文件根据你使用的 shell 编辑你的 shell 配置文件根据你使用的 shell
</p> </p>
@@ -781,18 +825,20 @@
<!-- Gemini CLI 环境变量设置 --> <!-- Gemini CLI 环境变量设置 -->
<div class="mt-8"> <div class="mt-8">
<h5 <h5
class="mb-2 flex items-center text-base font-semibold text-gray-800 sm:mb-3 sm:text-lg" class="mb-2 flex items-center text-base font-semibold text-gray-800 dark:text-gray-600 sm:mb-3 sm:text-lg"
> >
<i class="fas fa-robot mr-2 text-green-600" /> <i class="fas fa-robot mr-2 text-green-600" />
配置 Gemini CLI 环境变量 配置 Gemini CLI 环境变量
</h5> </h5>
<p class="mb-3 text-sm text-gray-700 sm:mb-4 sm:text-base"> <p class="mb-3 text-sm text-gray-700 dark:text-gray-300 sm:mb-4 sm:text-base">
如果你使用 Gemini CLI需要设置以下环境变量 如果你使用 Gemini CLI需要设置以下环境变量
</p> </p>
<div class="space-y-4"> <div class="space-y-4">
<div class="rounded-lg border border-green-200 bg-white p-3 sm:p-4"> <div class="rounded-lg border border-green-200 bg-white p-3 sm:p-4">
<h6 class="mb-2 text-sm font-medium text-gray-800 sm:text-base">Terminal 设置方法</h6> <h6 class="mb-2 text-sm font-medium text-gray-800 dark:text-gray-600 sm:text-base">
Terminal 设置方法
</h6>
<p class="mb-3 text-sm text-gray-600"> Terminal 中运行以下命令</p> <p class="mb-3 text-sm text-gray-600"> Terminal 中运行以下命令</p>
<div <div
class="overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm" class="overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
@@ -813,7 +859,9 @@
</div> </div>
<div class="rounded-lg border border-green-200 bg-white p-3 sm:p-4"> <div class="rounded-lg border border-green-200 bg-white p-3 sm:p-4">
<h6 class="mb-2 text-sm font-medium text-gray-800 sm:text-base">永久设置方法</h6> <h6 class="mb-2 text-sm font-medium text-gray-800 dark:text-gray-600 sm:text-base">
永久设置方法
</h6>
<p class="mb-3 text-sm text-gray-600">添加到你的 shell 配置文件</p> <p class="mb-3 text-sm text-gray-600">添加到你的 shell 配置文件</p>
<div <div
class="mb-3 overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm" class="mb-3 overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
@@ -848,7 +896,9 @@
</div> </div>
<div class="rounded-lg border border-green-200 bg-green-50 p-3 sm:p-4"> <div class="rounded-lg border border-green-200 bg-green-50 p-3 sm:p-4">
<h6 class="mb-2 font-medium text-green-800">验证 Gemini CLI 环境变量</h6> <h6 class="mb-2 font-medium text-green-800 dark:text-green-300">
验证 Gemini CLI 环境变量
</h6>
<p class="mb-3 text-sm text-green-700"> Terminal 中验证</p> <p class="mb-3 text-sm text-green-700"> Terminal 中验证</p>
<div <div
class="space-y-1 overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm" class="space-y-1 overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
@@ -864,18 +914,20 @@
<!-- Codex 环境变量设置 --> <!-- Codex 环境变量设置 -->
<div class="mt-8"> <div class="mt-8">
<h5 <h5
class="mb-2 flex items-center text-base font-semibold text-gray-800 sm:mb-3 sm:text-lg" class="mb-2 flex items-center text-base font-semibold text-gray-800 dark:text-gray-600 sm:mb-3 sm:text-lg"
> >
<i class="fas fa-code mr-2 text-indigo-600" /> <i class="fas fa-code mr-2 text-indigo-600" />
配置 Codex 环境变量 配置 Codex 环境变量
</h5> </h5>
<p class="mb-3 text-sm text-gray-700 sm:mb-4 sm:text-base"> <p class="mb-3 text-sm text-gray-700 dark:text-gray-300 sm:mb-4 sm:text-base">
如果你使用支持 OpenAI API 的工具 Codex需要设置以下环境变量 如果你使用支持 OpenAI API 的工具 Codex需要设置以下环境变量
</p> </p>
<div class="space-y-4"> <div class="space-y-4">
<div class="rounded-lg border border-indigo-200 bg-white p-3 sm:p-4"> <div class="rounded-lg border border-indigo-200 bg-white p-3 sm:p-4">
<h6 class="mb-2 text-sm font-medium text-gray-800 sm:text-base">Terminal 设置方法</h6> <h6 class="mb-2 text-sm font-medium text-gray-800 dark:text-gray-600 sm:text-base">
Terminal 设置方法
</h6>
<p class="mb-3 text-sm text-gray-600"> Terminal 中运行以下命令</p> <p class="mb-3 text-sm text-gray-600"> Terminal 中运行以下命令</p>
<div <div
class="overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm" class="overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
@@ -893,7 +945,9 @@
</div> </div>
<div class="rounded-lg border border-indigo-200 bg-white p-3 sm:p-4"> <div class="rounded-lg border border-indigo-200 bg-white p-3 sm:p-4">
<h6 class="mb-2 text-sm font-medium text-gray-800 sm:text-base">永久设置方法</h6> <h6 class="mb-2 text-sm font-medium text-gray-800 dark:text-gray-600 sm:text-base">
永久设置方法
</h6>
<p class="mb-3 text-sm text-gray-600">添加到你的 shell 配置文件</p> <p class="mb-3 text-sm text-gray-600">添加到你的 shell 配置文件</p>
<div <div
class="mb-3 overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm" class="mb-3 overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
@@ -937,7 +991,9 @@
<!-- 第四步开始使用 --> <!-- 第四步开始使用 -->
<div class="mb-8"> <div class="mb-8">
<h4 class="mb-3 flex items-center text-lg font-semibold text-gray-800 sm:mb-4 sm:text-xl"> <h4
class="mb-3 flex items-center text-lg font-semibold text-gray-800 dark:text-gray-300 sm:mb-4 sm:text-xl"
>
<span <span
class="mr-2 flex h-6 w-6 items-center justify-center rounded-full bg-yellow-500 text-xs font-bold text-white sm:mr-3 sm:h-8 sm:w-8 sm:text-sm" class="mr-2 flex h-6 w-6 items-center justify-center rounded-full bg-yellow-500 text-xs font-bold text-white sm:mr-3 sm:h-8 sm:w-8 sm:text-sm"
>4</span >4</span
@@ -947,13 +1003,15 @@
<div <div
class="rounded-xl border border-yellow-100 bg-gradient-to-r from-yellow-50 to-amber-50 p-4 sm:p-6" class="rounded-xl border border-yellow-100 bg-gradient-to-r from-yellow-50 to-amber-50 p-4 sm:p-6"
> >
<p class="mb-3 text-sm text-gray-700 sm:mb-4 sm:text-base"> <p class="mb-3 text-sm text-gray-700 dark:text-gray-300 sm:mb-4 sm:text-base">
现在你可以开始使用 Claude Code 现在你可以开始使用 Claude Code
</p> </p>
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<h6 class="mb-2 text-sm font-medium text-gray-800 sm:text-base">启动 Claude Code</h6> <h6 class="mb-2 text-sm font-medium text-gray-800 dark:text-gray-600 sm:text-base">
启动 Claude Code
</h6>
<div <div
class="overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm" class="overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
> >
@@ -962,7 +1020,9 @@
</div> </div>
<div> <div>
<h6 class="mb-2 text-sm font-medium text-gray-800 sm:text-base">在特定项目中使用</h6> <h6 class="mb-2 text-sm font-medium text-gray-800 dark:text-gray-600 sm:text-base">
在特定项目中使用
</h6>
<div <div
class="overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm" class="overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
> >
@@ -978,7 +1038,9 @@
<!-- macOS 故障排除 --> <!-- macOS 故障排除 -->
<div class="mb-8"> <div class="mb-8">
<h4 class="mb-3 flex items-center text-lg font-semibold text-gray-800 sm:mb-4 sm:text-xl"> <h4
class="mb-3 flex items-center text-lg font-semibold text-gray-800 dark:text-gray-300 sm:mb-4 sm:text-xl"
>
<i class="fas fa-wrench mr-2 text-red-600 sm:mr-3" /> <i class="fas fa-wrench mr-2 text-red-600 sm:mr-3" />
macOS 常见问题解决 macOS 常见问题解决
</h4> </h4>
@@ -1054,7 +1116,9 @@
<div v-else-if="activeTutorialSystem === 'linux'" class="tutorial-content"> <div v-else-if="activeTutorialSystem === 'linux'" class="tutorial-content">
<!-- 第一步安装 Node.js --> <!-- 第一步安装 Node.js -->
<div class="mb-6 sm:mb-10"> <div class="mb-6 sm:mb-10">
<h4 class="mb-3 flex items-center text-lg font-semibold text-gray-800 sm:mb-4 sm:text-xl"> <h4
class="mb-3 flex items-center text-lg font-semibold text-gray-800 dark:text-gray-300 sm:mb-4 sm:text-xl"
>
<span <span
class="mr-2 flex h-6 w-6 items-center justify-center rounded-full bg-blue-500 text-xs font-bold text-white sm:mr-3 sm:h-8 sm:w-8 sm:text-sm" class="mr-2 flex h-6 w-6 items-center justify-center rounded-full bg-blue-500 text-xs font-bold text-white sm:mr-3 sm:h-8 sm:w-8 sm:text-sm"
>1</span >1</span
@@ -1067,7 +1131,7 @@
class="mb-4 rounded-xl border border-orange-100 bg-gradient-to-r from-orange-50 to-red-50 p-4 sm:mb-6 sm:p-6" class="mb-4 rounded-xl border border-orange-100 bg-gradient-to-r from-orange-50 to-red-50 p-4 sm:mb-6 sm:p-6"
> >
<h5 <h5
class="mb-2 flex items-center text-base font-semibold text-gray-800 sm:mb-3 sm:text-lg" class="mb-2 flex items-center text-base font-semibold text-gray-800 dark:text-gray-600 sm:mb-3 sm:text-lg"
> >
<i class="fab fa-ubuntu mr-2 text-orange-600" /> <i class="fab fa-ubuntu mr-2 text-orange-600" />
Linux 安装方法 Linux 安装方法
@@ -1087,7 +1151,7 @@
</div> </div>
<div class="mb-4"> <div class="mb-4">
<p class="mb-3 text-gray-700">方法二使用系统包管理器</p> <p class="mb-3 text-gray-700">方法二使用系统包管理器</p>
<p class="mb-2 text-xs text-gray-600 sm:text-sm"> <p class="mb-2 text-xs text-gray-600 dark:text-gray-400 sm:text-sm">
虽然版本可能不是最新的但对于基本使用已经足够 虽然版本可能不是最新的但对于基本使用已经足够
</p> </p>
<div <div
@@ -1112,7 +1176,7 @@
<!-- 验证安装 --> <!-- 验证安装 -->
<div class="rounded-lg border border-green-200 bg-green-50 p-3 sm:p-4"> <div class="rounded-lg border border-green-200 bg-green-50 p-3 sm:p-4">
<h6 class="mb-2 font-medium text-green-800">验证安装是否成功</h6> <h6 class="mb-2 font-medium text-green-800 dark:text-green-300">验证安装是否成功</h6>
<p class="mb-3 text-sm text-green-700">安装完成后打开终端输入以下命令</p> <p class="mb-3 text-sm text-green-700">安装完成后打开终端输入以下命令</p>
<div <div
class="overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm" class="overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
@@ -1126,7 +1190,9 @@
<!-- 第二步安装 Claude Code --> <!-- 第二步安装 Claude Code -->
<div class="mb-6 sm:mb-10"> <div class="mb-6 sm:mb-10">
<h4 class="mb-3 flex items-center text-lg font-semibold text-gray-800 sm:mb-4 sm:text-xl"> <h4
class="mb-3 flex items-center text-lg font-semibold text-gray-800 dark:text-gray-300 sm:mb-4 sm:text-xl"
>
<span <span
class="mr-2 flex h-6 w-6 items-center justify-center rounded-full bg-green-500 text-xs font-bold text-white sm:mr-3 sm:h-8 sm:w-8 sm:text-sm" class="mr-2 flex h-6 w-6 items-center justify-center rounded-full bg-green-500 text-xs font-bold text-white sm:mr-3 sm:h-8 sm:w-8 sm:text-sm"
>2</span >2</span
@@ -1138,12 +1204,14 @@
class="mb-4 rounded-xl border border-purple-100 bg-gradient-to-r from-purple-50 to-pink-50 p-4 sm:mb-6 sm:p-6" class="mb-4 rounded-xl border border-purple-100 bg-gradient-to-r from-purple-50 to-pink-50 p-4 sm:mb-6 sm:p-6"
> >
<h5 <h5
class="mb-2 flex items-center text-base font-semibold text-gray-800 sm:mb-3 sm:text-lg" class="mb-2 flex items-center text-base font-semibold text-gray-800 dark:text-gray-600 sm:mb-3 sm:text-lg"
> >
<i class="fas fa-download mr-2 text-purple-600" /> <i class="fas fa-download mr-2 text-purple-600" />
安装 Claude Code 安装 Claude Code
</h5> </h5>
<p class="mb-3 text-sm text-gray-700 sm:mb-4 sm:text-base">打开终端运行以下命令</p> <p class="mb-3 text-sm text-gray-700 dark:text-gray-300 sm:mb-4 sm:text-base">
打开终端运行以下命令
</p>
<div <div
class="mb-4 overflow-x-auto rounded-lg bg-gray-900 p-3 font-mono text-xs text-green-400 sm:p-4 sm:text-sm" class="mb-4 overflow-x-auto rounded-lg bg-gray-900 p-3 font-mono text-xs text-green-400 sm:p-4 sm:text-sm"
> >
@@ -1164,7 +1232,7 @@
<!-- 验证安装 --> <!-- 验证安装 -->
<div class="rounded-lg border border-green-200 bg-green-50 p-3 sm:p-4"> <div class="rounded-lg border border-green-200 bg-green-50 p-3 sm:p-4">
<h6 class="mb-2 font-medium text-green-800">验证 Claude Code 安装</h6> <h6 class="mb-2 font-medium text-green-800 dark:text-green-300">验证 Claude Code 安装</h6>
<p class="mb-3 text-sm text-green-700">安装完成后输入以下命令检查是否安装成功</p> <p class="mb-3 text-sm text-green-700">安装完成后输入以下命令检查是否安装成功</p>
<div <div
class="overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm" class="overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
@@ -1179,7 +1247,9 @@
<!-- 第三步设置环境变量 --> <!-- 第三步设置环境变量 -->
<div class="mb-6 sm:mb-10"> <div class="mb-6 sm:mb-10">
<h4 class="mb-3 flex items-center text-lg font-semibold text-gray-800 sm:mb-4 sm:text-xl"> <h4
class="mb-3 flex items-center text-lg font-semibold text-gray-800 dark:text-gray-300 sm:mb-4 sm:text-xl"
>
<span <span
class="mr-2 flex h-6 w-6 items-center justify-center rounded-full bg-orange-500 text-xs font-bold text-white sm:mr-3 sm:h-8 sm:w-8 sm:text-sm" class="mr-2 flex h-6 w-6 items-center justify-center rounded-full bg-orange-500 text-xs font-bold text-white sm:mr-3 sm:h-8 sm:w-8 sm:text-sm"
>3</span >3</span
@@ -1191,18 +1261,18 @@
class="mb-4 rounded-xl border border-orange-100 bg-gradient-to-r from-orange-50 to-yellow-50 p-4 sm:mb-6 sm:p-6" class="mb-4 rounded-xl border border-orange-100 bg-gradient-to-r from-orange-50 to-yellow-50 p-4 sm:mb-6 sm:p-6"
> >
<h5 <h5
class="mb-2 flex items-center text-base font-semibold text-gray-800 sm:mb-3 sm:text-lg" class="mb-2 flex items-center text-base font-semibold text-gray-800 dark:text-gray-600 sm:mb-3 sm:text-lg"
> >
<i class="fas fa-cog mr-2 text-orange-600" /> <i class="fas fa-cog mr-2 text-orange-600" />
配置 Claude Code 环境变量 配置 Claude Code 环境变量
</h5> </h5>
<p class="mb-3 text-sm text-gray-700 sm:mb-4 sm:text-base"> <p class="mb-3 text-sm text-gray-700 dark:text-gray-300 sm:mb-4 sm:text-base">
为了让 Claude Code 连接到你的中转服务需要设置两个环境变量 为了让 Claude Code 连接到你的中转服务需要设置两个环境变量
</p> </p>
<div class="space-y-4"> <div class="space-y-4">
<div class="rounded-lg border border-orange-200 bg-white p-3 sm:p-4"> <div class="rounded-lg border border-orange-200 bg-white p-3 sm:p-4">
<h6 class="mb-2 text-sm font-medium text-gray-800 sm:text-base"> <h6 class="mb-2 text-sm font-medium text-gray-800 dark:text-gray-600 sm:text-base">
方法一临时设置当前会话 方法一临时设置当前会话
</h6> </h6>
<p class="mb-3 text-sm text-gray-600">在终端中运行以下命令</p> <p class="mb-3 text-sm text-gray-600">在终端中运行以下命令</p>
@@ -1222,7 +1292,9 @@
</div> </div>
<div class="rounded-lg border border-orange-200 bg-white p-3 sm:p-4"> <div class="rounded-lg border border-orange-200 bg-white p-3 sm:p-4">
<h6 class="mb-2 text-sm font-medium text-gray-800 sm:text-base">方法二永久设置</h6> <h6 class="mb-2 text-sm font-medium text-gray-800 dark:text-gray-600 sm:text-base">
方法二永久设置
</h6>
<p class="mb-3 text-sm text-gray-600">编辑你的 shell 配置文件</p> <p class="mb-3 text-sm text-gray-600">编辑你的 shell 配置文件</p>
<div <div
class="mb-3 overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm" class="mb-3 overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
@@ -1255,18 +1327,20 @@
<!-- Gemini CLI 环境变量设置 --> <!-- Gemini CLI 环境变量设置 -->
<div class="mt-8"> <div class="mt-8">
<h5 <h5
class="mb-2 flex items-center text-base font-semibold text-gray-800 sm:mb-3 sm:text-lg" class="mb-2 flex items-center text-base font-semibold text-gray-800 dark:text-gray-600 sm:mb-3 sm:text-lg"
> >
<i class="fas fa-robot mr-2 text-green-600" /> <i class="fas fa-robot mr-2 text-green-600" />
配置 Gemini CLI 环境变量 配置 Gemini CLI 环境变量
</h5> </h5>
<p class="mb-3 text-sm text-gray-700 sm:mb-4 sm:text-base"> <p class="mb-3 text-sm text-gray-700 dark:text-gray-300 sm:mb-4 sm:text-base">
如果你使用 Gemini CLI需要设置以下环境变量 如果你使用 Gemini CLI需要设置以下环境变量
</p> </p>
<div class="space-y-4"> <div class="space-y-4">
<div class="rounded-lg border border-green-200 bg-white p-3 sm:p-4"> <div class="rounded-lg border border-green-200 bg-white p-3 sm:p-4">
<h6 class="mb-2 text-sm font-medium text-gray-800 sm:text-base">终端设置方法</h6> <h6 class="mb-2 text-sm font-medium text-gray-800 dark:text-gray-600 sm:text-base">
终端设置方法
</h6>
<p class="mb-3 text-sm text-gray-600">在终端中运行以下命令</p> <p class="mb-3 text-sm text-gray-600">在终端中运行以下命令</p>
<div <div
class="overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm" class="overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
@@ -1287,7 +1361,9 @@
</div> </div>
<div class="rounded-lg border border-green-200 bg-white p-3 sm:p-4"> <div class="rounded-lg border border-green-200 bg-white p-3 sm:p-4">
<h6 class="mb-2 text-sm font-medium text-gray-800 sm:text-base">永久设置方法</h6> <h6 class="mb-2 text-sm font-medium text-gray-800 dark:text-gray-600 sm:text-base">
永久设置方法
</h6>
<p class="mb-3 text-sm text-gray-600">添加到你的 shell 配置文件</p> <p class="mb-3 text-sm text-gray-600">添加到你的 shell 配置文件</p>
<div <div
class="mb-3 overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm" class="mb-3 overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
@@ -1322,7 +1398,9 @@
</div> </div>
<div class="rounded-lg border border-green-200 bg-green-50 p-3 sm:p-4"> <div class="rounded-lg border border-green-200 bg-green-50 p-3 sm:p-4">
<h6 class="mb-2 font-medium text-green-800">验证 Gemini CLI 环境变量</h6> <h6 class="mb-2 font-medium text-green-800 dark:text-green-300">
验证 Gemini CLI 环境变量
</h6>
<p class="mb-3 text-sm text-green-700">在终端中验证</p> <p class="mb-3 text-sm text-green-700">在终端中验证</p>
<div <div
class="space-y-1 overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm" class="space-y-1 overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
@@ -1338,18 +1416,20 @@
<!-- Codex 环境变量设置 --> <!-- Codex 环境变量设置 -->
<div class="mt-8"> <div class="mt-8">
<h5 <h5
class="mb-2 flex items-center text-base font-semibold text-gray-800 sm:mb-3 sm:text-lg" class="mb-2 flex items-center text-base font-semibold text-gray-800 dark:text-gray-600 sm:mb-3 sm:text-lg"
> >
<i class="fas fa-code mr-2 text-indigo-600" /> <i class="fas fa-code mr-2 text-indigo-600" />
配置 Codex 环境变量 配置 Codex 环境变量
</h5> </h5>
<p class="mb-3 text-sm text-gray-700 sm:mb-4 sm:text-base"> <p class="mb-3 text-sm text-gray-700 dark:text-gray-300 sm:mb-4 sm:text-base">
如果你使用支持 OpenAI API 的工具 Codex需要设置以下环境变量 如果你使用支持 OpenAI API 的工具 Codex需要设置以下环境变量
</p> </p>
<div class="space-y-4"> <div class="space-y-4">
<div class="rounded-lg border border-indigo-200 bg-white p-3 sm:p-4"> <div class="rounded-lg border border-indigo-200 bg-white p-3 sm:p-4">
<h6 class="mb-2 text-sm font-medium text-gray-800 sm:text-base">终端设置方法</h6> <h6 class="mb-2 text-sm font-medium text-gray-800 dark:text-gray-600 sm:text-base">
终端设置方法
</h6>
<p class="mb-3 text-sm text-gray-600">在终端中运行以下命令</p> <p class="mb-3 text-sm text-gray-600">在终端中运行以下命令</p>
<div <div
class="overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm" class="overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
@@ -1367,7 +1447,9 @@
</div> </div>
<div class="rounded-lg border border-indigo-200 bg-white p-3 sm:p-4"> <div class="rounded-lg border border-indigo-200 bg-white p-3 sm:p-4">
<h6 class="mb-2 text-sm font-medium text-gray-800 sm:text-base">永久设置方法</h6> <h6 class="mb-2 text-sm font-medium text-gray-800 dark:text-gray-600 sm:text-base">
永久设置方法
</h6>
<p class="mb-3 text-sm text-gray-600">添加到你的 shell 配置文件</p> <p class="mb-3 text-sm text-gray-600">添加到你的 shell 配置文件</p>
<div <div
class="mb-3 overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm" class="mb-3 overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
@@ -1411,7 +1493,9 @@
<!-- 第四步开始使用 --> <!-- 第四步开始使用 -->
<div class="mb-8"> <div class="mb-8">
<h4 class="mb-3 flex items-center text-lg font-semibold text-gray-800 sm:mb-4 sm:text-xl"> <h4
class="mb-3 flex items-center text-lg font-semibold text-gray-800 dark:text-gray-300 sm:mb-4 sm:text-xl"
>
<span <span
class="mr-2 flex h-6 w-6 items-center justify-center rounded-full bg-yellow-500 text-xs font-bold text-white sm:mr-3 sm:h-8 sm:w-8 sm:text-sm" class="mr-2 flex h-6 w-6 items-center justify-center rounded-full bg-yellow-500 text-xs font-bold text-white sm:mr-3 sm:h-8 sm:w-8 sm:text-sm"
>4</span >4</span
@@ -1421,13 +1505,15 @@
<div <div
class="rounded-xl border border-yellow-100 bg-gradient-to-r from-yellow-50 to-amber-50 p-4 sm:p-6" class="rounded-xl border border-yellow-100 bg-gradient-to-r from-yellow-50 to-amber-50 p-4 sm:p-6"
> >
<p class="mb-3 text-sm text-gray-700 sm:mb-4 sm:text-base"> <p class="mb-3 text-sm text-gray-700 dark:text-gray-300 sm:mb-4 sm:text-base">
现在你可以开始使用 Claude Code 现在你可以开始使用 Claude Code
</p> </p>
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<h6 class="mb-2 text-sm font-medium text-gray-800 sm:text-base">启动 Claude Code</h6> <h6 class="mb-2 text-sm font-medium text-gray-800 dark:text-gray-600 sm:text-base">
启动 Claude Code
</h6>
<div <div
class="overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm" class="overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
> >
@@ -1436,7 +1522,9 @@
</div> </div>
<div> <div>
<h6 class="mb-2 text-sm font-medium text-gray-800 sm:text-base">在特定项目中使用</h6> <h6 class="mb-2 text-sm font-medium text-gray-800 dark:text-gray-600 sm:text-base">
在特定项目中使用
</h6>
<div <div
class="overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm" class="overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
> >
@@ -1452,7 +1540,9 @@
<!-- Linux 故障排除 --> <!-- Linux 故障排除 -->
<div class="mb-8"> <div class="mb-8">
<h4 class="mb-3 flex items-center text-lg font-semibold text-gray-800 sm:mb-4 sm:text-xl"> <h4
class="mb-3 flex items-center text-lg font-semibold text-gray-800 dark:text-gray-300 sm:mb-4 sm:text-xl"
>
<i class="fas fa-wrench mr-2 text-red-600 sm:mr-3" /> <i class="fas fa-wrench mr-2 text-red-600 sm:mr-3" />
Linux 常见问题解决 Linux 常见问题解决
</h4> </h4>

View File

@@ -1,6 +1,7 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
export default { export default {
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'], content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
darkMode: 'class',
theme: { theme: {
extend: { extend: {
animation: { animation: {