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
MAX_PROXY_RETRIES=3
# IP协议族配置true=IPv4, false=IPv6, 默认IPv4兼容性更好
PROXY_USE_IPV4=true
# 📈 使用限制
DEFAULT_TOKEN_LIMIT=1000000
@@ -43,8 +45,7 @@ LOG_MAX_FILES=5
CLEANUP_INTERVAL=3600000
TOKEN_USAGE_RETENTION=2592000000
HEALTH_CHECK_INTERVAL=60000
SYSTEM_TIMEZONE=Asia/Shanghai
TIMEZONE_OFFSET=8
TIMEZONE_OFFSET=8 # UTC偏移小时数默认+8中国时区
METRICS_WINDOW=5 # 实时指标统计窗口分钟可选1-60默认5分钟
# 🎨 Web 界面配置

6
.gitignore vendored
View File

@@ -13,6 +13,10 @@ pnpm-debug.log*
# Claude specific directories
.claude/
# MCP configuration (local only)
.mcp.json
.spec-workflow/
# Data directory (contains sensitive information)
data/
!data/.gitkeep
@@ -237,4 +241,4 @@ web/admin/
web/apiStats/
# Admin SPA build files
web/admin-spa/dist/
web/admin-spa/dist/

View File

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

View File

@@ -1 +1 @@
1.1.115
1.1.120

View File

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

View File

@@ -79,7 +79,7 @@ async function testApiResponse() {
console.log('\n\n📊 验证结果:')
// 检查 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')
if (claudeWithPlatform.length === claudeAccounts.length) {

View File

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

View File

@@ -5,6 +5,7 @@ const claudeConsoleAccountService = require('../services/claudeConsoleAccountSer
const bedrockAccountService = require('../services/bedrockAccountService')
const geminiAccountService = require('../services/geminiAccountService')
const openaiAccountService = require('../services/openaiAccountService')
const azureOpenaiAccountService = require('../services/azureOpenaiAccountService')
const accountGroupService = require('../services/accountGroupService')
const redis = require('../models/redis')
const { authenticateAdmin } = require('../middleware/auth')
@@ -13,13 +14,13 @@ const oauthHelper = require('../utils/oauthHelper')
const CostCalculator = require('../utils/costCalculator')
const pricingService = require('../services/pricingService')
const claudeCodeHeadersService = require('../services/claudeCodeHeadersService')
const webhookNotifier = require('../utils/webhookNotifier')
const axios = require('axios')
const crypto = require('crypto')
const fs = require('fs')
const path = require('path')
const config = require('../../config/config')
const { SocksProxyAgent } = require('socks-proxy-agent')
const { HttpsProxyAgent } = require('https-proxy-agent')
const ProxyHelper = require('../utils/proxyHelper')
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
router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
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) => {
try {
const { keyId } = req.params
@@ -1268,6 +1531,7 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
claudeAiOauth,
proxy,
accountType,
platform = 'claude',
priority,
groupId
} = req.body
@@ -1305,6 +1569,7 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
claudeAiOauth,
proxy,
accountType: accountType || 'shared', // 默认为共享类型
platform,
priority: priority || 50 // 默认优先级为50
})
@@ -1498,6 +1763,19 @@ router.put(
const newSchedulable = !account.schedulable
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(
`🔄 Admin toggled Claude account schedulable status: ${accountId} -> ${newSchedulable ? 'schedulable' : 'not schedulable'}`
)
@@ -1768,6 +2046,19 @@ router.put(
const newSchedulable = !account.schedulable
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(
`🔄 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 })
}
// 如果账号被禁用发送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(
`🔄 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
router.post('/gemini-accounts/generate-auth-url', authenticateAdmin, async (req, res) => {
try {
const { state } = req.body
const { state, proxy } = req.body // 接收代理配置
// 使用新的 codeassist.google.com 回调地址
const redirectUri = 'https://codeassist.google.com/authcode'
@@ -2093,13 +2397,14 @@ router.post('/gemini-accounts/generate-auth-url', authenticateAdmin, async (req,
redirectUri: finalRedirectUri
} = await geminiAccountService.generateAuthUrl(state, redirectUri)
// 创建 OAuth 会话,包含 codeVerifier
// 创建 OAuth 会话,包含 codeVerifier 和代理配置
const sessionId = authState
await redis.setOAuthSession(sessionId, {
state: authState,
type: 'gemini',
redirectUri: finalRedirectUri,
codeVerifier, // 保存 PKCE code verifier
proxy: proxy || null, // 保存代理配置
createdAt: new Date().toISOString()
})
@@ -2143,7 +2448,7 @@ router.post('/gemini-accounts/poll-auth-status', authenticateAdmin, async (req,
// 交换 Gemini 授权码
router.post('/gemini-accounts/exchange-code', authenticateAdmin, async (req, res) => {
try {
const { code, sessionId } = req.body
const { code, sessionId, proxy: requestProxy } = req.body
if (!code) {
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 codeVerifier = null
let proxyConfig = null
// 如果提供了 sessionId从 OAuth 会话中获取信息
if (sessionId) {
const sessionData = await redis.getOAuthSession(sessionId)
if (sessionData) {
const { redirectUri: sessionRedirectUri, codeVerifier: sessionCodeVerifier } = sessionData
const {
redirectUri: sessionRedirectUri,
codeVerifier: sessionCodeVerifier,
proxy
} = sessionData
redirectUri = sessionRedirectUri || redirectUri
codeVerifier = sessionCodeVerifier
proxyConfig = proxy // 获取代理配置
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 会话
if (sessionId) {
@@ -2393,6 +2717,19 @@ router.put(
const updatedAccount = await geminiAccountService.getAccount(accountId)
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(
`🔄 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
if (type === 'socks5') {
// SOCKS5 代理
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)
}
// 配置代理(如果有)
const proxyAgent = ProxyHelper.createProxyAgent(sessionData.proxy)
if (proxyAgent) {
axiosConfig.httpsAgent = proxyAgent
}
// 交换 authorization code 获取 tokens
@@ -4963,6 +5291,23 @@ router.put(
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({
success: result.success,
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

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

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)
// 解析账户的代理配置
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(
client,
{ model, request: actualRequestData },
user_prompt_id,
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)
} catch (error) {
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({
error: {
message: error.message || 'Internal server error',
@@ -654,13 +675,25 @@ async function handleStreamGenerateContent(req, res) {
})
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(
client,
{ model, request: actualRequestData },
user_prompt_id,
account.projectId, // 始终使用账户配置的项目ID忽略请求中的project
req.apiKey?.id, // 使用 API Key ID 作为 session ID
abortController.signal // 传递中止信号
abortController.signal, // 传递中止信号
proxyConfig // 传递代理配置
)
// 设置 SSE 响应头
@@ -756,7 +789,16 @@ async function handleStreamGenerateContent(req, res) {
})
} catch (error) {
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) {
res.status(500).json({

View File

@@ -8,30 +8,11 @@ const unifiedOpenAIScheduler = require('../services/unifiedOpenAIScheduler')
const openaiAccountService = require('../services/openaiAccountService')
const apiKeyService = require('../services/apiKeyService')
const crypto = require('crypto')
const { SocksProxyAgent } = require('socks-proxy-agent')
const { HttpsProxyAgent } = require('https-proxy-agent')
const ProxyHelper = require('../utils/proxyHelper')
// 创建代理 Agent
// 创建代理 Agent(使用统一的代理工具)
function createProxyAgent(proxy) {
if (!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
return ProxyHelper.createProxyAgent(proxy)
}
// 使用统一调度器选择 OpenAI 账户
@@ -80,7 +61,8 @@ async function getOpenAIAuthToken(apiKeyData, sessionId = null, requestedModel =
accessToken,
accountId: result.accountId,
accountName: account.name,
proxy
proxy,
account
}
} catch (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(
apiKeyData,
sessionId,
requestedModel
)
const {
accessToken,
accountId,
accountName: _accountName,
proxy,
account
} = await getOpenAIAuthToken(apiKeyData, sessionId, requestedModel)
// 基于白名单构造上游所需的请求头,确保键为小写且值受控
const incoming = req.headers || {}
@@ -165,7 +149,7 @@ router.post('/responses', authenticateApiKey, async (req, res) => {
// 覆盖或新增必要头部
headers['authorization'] = `Bearer ${accessToken}`
headers['chatgpt-account-id'] = accountId
headers['chatgpt-account-id'] = account.accountId || account.chatgptUserId || accountId
headers['host'] = 'chatgpt.com'
headers['accept'] = isStream ? 'text/event-stream' : 'application/json'
headers['content-type'] = 'application/json'
@@ -184,7 +168,9 @@ router.post('/responses', authenticateApiKey, async (req, res) => {
// 如果有代理,添加代理配置
if (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 参数决定请求类型

View File

@@ -1,18 +1,125 @@
const express = require('express')
const router = express.Router()
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')
// 获取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连通性
router.post('/test', authenticateAdmin, async (req, res) => {
try {
const { url } = req.body
const { url, type = 'custom', secret, enableSign } = req.body
if (!url) {
return res.status(400).json({
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) {
return res.status(400).json({
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) {
logger.info(`✅ Webhook test successful for: ${url}`)
logger.info(`✅ Webhook测试成功: ${url}`)
res.json({
success: true,
message: 'Webhook test successful',
message: 'Webhook测试成功',
url
})
} else {
logger.warn(`❌ Webhook test failed for: ${url} - ${result.error}`)
logger.warn(`❌ Webhook测试失败: ${url} - ${result.error}`)
res.status(400).json({
success: false,
message: 'Webhook test failed',
message: 'Webhook测试失败',
url,
error: result.error
})
}
} catch (error) {
logger.error('❌ Webhook test error:', error)
logger.error('❌ Webhook测试错误:', error)
res.status(500).json({
error: 'Internal server error',
message: 'Failed to test webhook'
message: '测试webhook失败'
})
}
})
// 手动触发账号异常通知(用于测试)
// 手动触发测试通知
router.post('/test-notification', authenticateAdmin, async (req, res) => {
try {
const {
type = 'test',
accountId = 'test-account-id',
accountName = 'Test Account',
accountName = '测试账号',
platform = 'claude-oauth',
status = 'error',
errorCode = 'TEST_ERROR',
reason = 'Manual test notification'
status = 'test',
errorCode = 'TEST_NOTIFICATION',
reason = '手动测试通知',
message = '这是一条测试通知消息,用于验证 Webhook 通知功能是否正常工作'
} = 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,
accountName,
platform,
status,
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({
success: true,
message: 'Test notification sent successfully',
data: {
accountId,
accountName,
platform,
status,
errorCode,
reason
}
message: `测试通知已成功发送到 ${result.succeeded} 个平台`,
data: testData,
result
})
} catch (error) {
logger.error('❌ Failed to send test notification:', error)
logger.error('❌ 发送测试通知失败:', error)
res.status(500).json({
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

View File

@@ -20,6 +20,7 @@ class ApiKeyService {
claudeConsoleAccountId = null,
geminiAccountId = null,
openaiAccountId = null,
azureOpenaiAccountId = null,
bedrockAccountId = null, // 添加 Bedrock 账号ID支持
permissions = 'all', // 'claude', 'gemini', 'openai', 'all'
isActive = true,
@@ -53,6 +54,7 @@ class ApiKeyService {
claudeConsoleAccountId: claudeConsoleAccountId || '',
geminiAccountId: geminiAccountId || '',
openaiAccountId: openaiAccountId || '',
azureOpenaiAccountId: azureOpenaiAccountId || '',
bedrockAccountId: bedrockAccountId || '', // 添加 Bedrock 账号ID
permissions: permissions || 'all',
enableModelRestriction: String(enableModelRestriction),
@@ -88,6 +90,7 @@ class ApiKeyService {
claudeConsoleAccountId: keyData.claudeConsoleAccountId,
geminiAccountId: keyData.geminiAccountId,
openaiAccountId: keyData.openaiAccountId,
azureOpenaiAccountId: keyData.azureOpenaiAccountId,
bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID
permissions: keyData.permissions,
enableModelRestriction: keyData.enableModelRestriction === 'true',
@@ -190,6 +193,7 @@ class ApiKeyService {
claudeConsoleAccountId: keyData.claudeConsoleAccountId,
geminiAccountId: keyData.geminiAccountId,
openaiAccountId: keyData.openaiAccountId,
azureOpenaiAccountId: keyData.azureOpenaiAccountId,
bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID
permissions: keyData.permissions || 'all',
tokenLimit: parseInt(keyData.tokenLimit),
@@ -337,6 +341,7 @@ class ApiKeyService {
'claudeConsoleAccountId',
'geminiAccountId',
'openaiAccountId',
'azureOpenaiAccountId',
'bedrockAccountId', // 添加 Bedrock 账号ID
'permissions',
'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 crypto = require('crypto')
const { SocksProxyAgent } = require('socks-proxy-agent')
const { HttpsProxyAgent } = require('https-proxy-agent')
const ProxyHelper = require('../utils/proxyHelper')
const axios = require('axios')
const redis = require('../models/redis')
const logger = require('../utils/logger')
@@ -55,6 +54,7 @@ class ClaudeAccountService {
proxy = null, // { type: 'socks5', host: 'localhost', port: 1080, username: '', password: '' }
isActive = true,
accountType = 'shared', // 'dedicated' or 'shared'
platform = 'claude',
priority = 50, // 调度优先级 (1-100数字越小优先级越高)
schedulable = true, // 是否可被调度
subscriptionInfo = null // 手动设置的订阅信息
@@ -79,7 +79,8 @@ class ClaudeAccountService {
scopes: claudeAiOauth.scopes.join(' '),
proxy: proxy ? JSON.stringify(proxy) : '',
isActive: isActive.toString(),
accountType, // 账号类型:'dedicated' 或 'shared'
accountType, // 账号类型:'dedicated' 或 'shared' 或 'group'
platform,
priority: priority.toString(), // 调度优先级
createdAt: new Date().toISOString(),
lastUsedAt: '',
@@ -108,7 +109,8 @@ class ClaudeAccountService {
scopes: '',
proxy: proxy ? JSON.stringify(proxy) : '',
isActive: isActive.toString(),
accountType, // 账号类型:'dedicated' 或 'shared'
accountType, // 账号类型:'dedicated' 或 'shared' 或 'group'
platform,
priority: priority.toString(), // 调度优先级
createdAt: new Date().toISOString(),
lastUsedAt: '',
@@ -151,6 +153,7 @@ class ClaudeAccountService {
isActive,
proxy,
accountType,
platform,
priority,
status: accountData.status,
createdAt: accountData.createdAt,
@@ -444,7 +447,7 @@ class ClaudeAccountService {
errorMessage: account.errorMessage,
accountType: account.accountType || 'shared', // 兼容旧数据,默认为共享
priority: parseInt(account.priority) || 50, // 兼容旧数据默认优先级50
platform: 'claude-oauth', // 添加平台标识,用于前端区分
platform: account.platform || 'claude', // 添加平台标识,用于前端区分
createdAt: account.createdAt,
lastUsedAt: account.lastUsedAt,
lastRefreshAt: account.lastRefreshAt,
@@ -857,29 +860,19 @@ class ClaudeAccountService {
}
}
// 🌐 创建代理agent
// 🌐 创建代理agent(使用统一的代理工具)
_createProxyAgent(proxyConfig) {
if (!proxyConfig) {
return null
const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
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')
}
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
return proxyAgent
}
// 🔐 加密敏感数据
@@ -1094,6 +1087,22 @@ class ClaudeAccountService {
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 }
} catch (error) {
logger.error(`❌ Failed to mark account as rate limited: ${accountId}`, error)

View File

@@ -1,7 +1,6 @@
const { v4: uuidv4 } = require('uuid')
const crypto = require('crypto')
const { SocksProxyAgent } = require('socks-proxy-agent')
const { HttpsProxyAgent } = require('https-proxy-agent')
const ProxyHelper = require('../utils/proxyHelper')
const redis = require('../models/redis')
const logger = require('../utils/logger')
const config = require('../../config/config')
@@ -367,6 +366,22 @@ class ClaudeConsoleAccountService {
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(
`🚫 Claude Console account marked as rate limited: ${account.name} (${accountId})`
)
@@ -480,29 +495,19 @@ class ClaudeConsoleAccountService {
}
}
// 🌐 创建代理agent
// 🌐 创建代理agent(使用统一的代理工具)
_createProxyAgent(proxyConfig) {
if (!proxyConfig) {
return null
const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig)
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')
}
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
return proxyAgent
}
// 🔐 加密敏感数据

View File

@@ -84,7 +84,16 @@ class ClaudeConsoleRelayService {
// 构建完整的API URL
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(`[DEBUG] Options passed to relayRequest: ${JSON.stringify(options)}`)

View File

@@ -2,8 +2,7 @@ const https = require('https')
const zlib = require('zlib')
const fs = require('fs')
const path = require('path')
const { SocksProxyAgent } = require('socks-proxy-agent')
const { HttpsProxyAgent } = require('https-proxy-agent')
const ProxyHelper = require('../utils/proxyHelper')
const claudeAccountService = require('./claudeAccountService')
const unifiedClaudeScheduler = require('./unifiedClaudeScheduler')
const sessionHelper = require('../utils/sessionHelper')
@@ -496,32 +495,28 @@ class ClaudeRelayService {
}
}
// 🌐 获取代理Agent
// 🌐 获取代理Agent(使用统一的代理工具)
async _getProxyAgent(accountId) {
try {
const accountData = await claudeAccountService.getAllAccounts()
const account = accountData.find((acc) => acc.id === accountId)
if (!account || !account.proxy) {
logger.debug('🌐 No proxy configured for Claude account')
return null
}
const { proxy } = account
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)
const proxyAgent = ProxyHelper.createProxyAgent(account.proxy)
if (proxyAgent) {
logger.info(
`🌐 Using proxy for Claude request: ${ProxyHelper.getProxyDescription(account.proxy)}`
)
}
return proxyAgent
} catch (error) {
logger.warn('⚠️ Failed to create proxy agent:', error)
return null
}
return null
}
// 🔧 过滤客户端请求头
@@ -596,10 +591,18 @@ class ClaudeRelayService {
}
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 = {
hostname: url.hostname,
port: url.port || 443,
path: url.pathname,
path: requestPath,
method: 'POST',
headers: {
'Content-Type': 'application/json',

View File

@@ -5,6 +5,7 @@ const config = require('../../config/config')
const logger = require('../utils/logger')
const { OAuth2Client } = require('google-auth-library')
const { maskToken } = require('../utils/tokenMask')
const ProxyHelper = require('../utils/proxyHelper')
const {
logRefreshStart,
logRefreshSuccess,
@@ -109,11 +110,32 @@ setInterval(
10 * 60 * 1000
)
// 创建 OAuth2 客户端
function createOAuth2Client(redirectUri = null) {
// 创建 OAuth2 客户端(支持代理配置)
function createOAuth2Client(redirectUri = null, proxyConfig = null) {
// 如果没有提供 redirectUri使用默认值
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)
@@ -196,11 +218,25 @@ async function pollAuthorizationStatus(sessionId, maxAttempts = 60, interval = 2
}
}
// 交换授权码获取 tokens (支持 PKCE)
async function exchangeCodeForTokens(code, redirectUri = null, codeVerifier = null) {
const oAuth2Client = createOAuth2Client(redirectUri)
// 交换授权码获取 tokens (支持 PKCE 和代理)
async function exchangeCodeForTokens(
code,
redirectUri = null,
codeVerifier = null,
proxyConfig = null
) {
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 = {
code,
redirect_uri: redirectUri
@@ -228,8 +264,9 @@ async function exchangeCodeForTokens(code, redirectUri = null, codeVerifier = nu
}
// 刷新访问令牌
async function refreshAccessToken(refreshToken) {
const oAuth2Client = createOAuth2Client()
async function refreshAccessToken(refreshToken, proxyConfig = null) {
// 创建带代理配置的 OAuth2Client
const oAuth2Client = createOAuth2Client(null, proxyConfig)
try {
// 设置 refresh_token
@@ -237,6 +274,14 @@ async function refreshAccessToken(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
const response = await oAuth2Client.refreshAccessToken()
const { credentials } = response
@@ -261,7 +306,9 @@ async function refreshAccessToken(refreshToken) {
logger.error('Error refreshing access token:', {
message: error.message,
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}`)
}
@@ -786,7 +833,8 @@ async function refreshAccountToken(accountId) {
logger.info(`🔄 Starting token refresh for Gemini account: ${account.name} (${accountId})`)
// account.refreshToken 已经是解密后的值(从 getAccount 返回)
const newTokens = await refreshAccessToken(account.refreshToken)
// 传入账户的代理配置
const newTokens = await refreshAccessToken(account.refreshToken, account.proxy)
// 更新账户信息
const updates = {
@@ -1169,7 +1217,8 @@ async function generateContent(
requestData,
userPromptId,
projectId = null,
sessionId = null
sessionId = null,
proxyConfig = null
) {
const axios = require('axios')
const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com'
@@ -1206,6 +1255,17 @@ async function generateContent(
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)
logger.info('✅ generateContent API调用成功')
@@ -1219,7 +1279,8 @@ async function generateContentStream(
userPromptId,
projectId = null,
sessionId = null,
signal = null
signal = null,
proxyConfig = null
) {
const axios = require('axios')
const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com'
@@ -1260,6 +1321,17 @@ async function generateContentStream(
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) {
axiosConfig.signal = signal

View File

@@ -1,6 +1,5 @@
const axios = require('axios')
const { HttpsProxyAgent } = require('https-proxy-agent')
const { SocksProxyAgent } = require('socks-proxy-agent')
const ProxyHelper = require('../utils/proxyHelper')
const logger = require('../utils/logger')
const config = require('../../config/config')
const apiKeyService = require('./apiKeyService')
@@ -9,34 +8,9 @@ const apiKeyService = require('./apiKeyService')
const GEMINI_API_BASE = 'https://cloudcode.googleapis.com/v1'
const DEFAULT_MODEL = 'models/gemini-2.0-flash-exp'
// 创建代理 agent
// 创建代理 agent(使用统一的代理工具)
function createProxyAgent(proxyConfig) {
if (!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
return ProxyHelper.createProxyAgent(proxyConfig)
}
// 转换 OpenAI 消息格式到 Gemini 格式
@@ -306,7 +280,9 @@ async function sendGeminiRequest({
const proxyAgent = createProxyAgent(proxy)
if (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 信号支持
@@ -412,6 +388,11 @@ async function getAvailableModels(accessToken, proxy, projectId, location = 'us-
const proxyAgent = createProxyAgent(proxy)
if (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 {
@@ -508,7 +489,11 @@ async function countTokens({
const proxyAgent = createProxyAgent(proxy)
if (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 {

View File

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

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 os = require('os')
// 安全的 JSON 序列化函数,处理循环引用
// 安全的 JSON 序列化函数,处理循环引用和特殊字符
const safeStringify = (obj, maxDepth = 3, fullDepth = false) => {
const seen = new WeakSet()
// 如果是fullDepth模式增加深度限制
@@ -16,6 +16,28 @@ const safeStringify = (obj, maxDepth = 3, fullDepth = false) => {
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 (seen.has(value)) {
return '[Circular Reference]'
@@ -40,7 +62,10 @@ const safeStringify = (obj, maxDepth = 3, fullDepth = false) => {
} else {
const result = {}
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
}
@@ -50,9 +75,20 @@ const safeStringify = (obj, maxDepth = 3, fullDepth = false) => {
}
try {
return JSON.stringify(replacer('', obj))
const processed = replacer('', obj)
return JSON.stringify(processed)
} 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 formats = [
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.errors({ stack: true }),
winston.format.metadata({ fillExcept: ['message', 'level', 'timestamp', 'stack'] })
winston.format.errors({ stack: true })
// 移除 winston.format.metadata() 来避免自动包装
]
if (colorize) {
@@ -69,7 +105,7 @@ const createLogFormat = (colorize = false) => {
}
formats.push(
winston.format.printf(({ level, message, timestamp, stack, metadata, ...rest }) => {
winston.format.printf(({ level, message, timestamp, stack, ...rest }) => {
const emoji = {
error: '❌',
warn: '⚠️ ',
@@ -80,12 +116,7 @@ const createLogFormat = (colorize = false) => {
let logMessage = `${emoji[level] || '📝'} [${timestamp}] ${level.toUpperCase()}: ${message}`
// 添加元数据
if (metadata && Object.keys(metadata).length > 0) {
logMessage += ` | ${safeStringify(metadata)}`
}
// 添加其他属性
// 直接处理额外数据不需要metadata包装
const additionalData = { ...rest }
delete additionalData.level
delete additionalData.message

View File

@@ -4,8 +4,7 @@
*/
const crypto = require('crypto')
const { SocksProxyAgent } = require('socks-proxy-agent')
const { HttpsProxyAgent } = require('https-proxy-agent')
const ProxyHelper = require('./proxyHelper')
const axios = require('axios')
const logger = require('./logger')
@@ -125,36 +124,12 @@ function generateSetupTokenParams() {
}
/**
* 创建代理agent
* 创建代理agent(使用统一的代理工具)
* @param {object|null} proxyConfig - 代理配置对象
* @returns {object|null} 代理agent或null
*/
function createProxyAgent(proxyConfig) {
if (!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
return ProxyHelper.createProxyAgent(proxyConfig)
}
/**
@@ -182,6 +157,14 @@ async function exchangeCodeForTokens(authorizationCode, codeVerifier, state, pro
const agent = createProxyAgent(proxyConfig)
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', {
url: OAUTH_CONFIG.TOKEN_URL,
codeLength: cleanedCode.length,
@@ -379,6 +362,14 @@ async function exchangeSetupTokenCode(authorizationCode, codeVerifier, state, pr
const agent = createProxyAgent(proxyConfig)
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', {
url: OAUTH_CONFIG.TOKEN_URL,
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 config = require('../../config/config')
const webhookService = require('../services/webhookService')
class WebhookNotifier {
constructor() {
this.webhookUrls = config.webhook?.urls || []
this.timeout = config.webhook?.timeout || 10000
this.retries = config.webhook?.retries || 3
this.enabled = config.webhook?.enabled !== false
// 保留此类用于兼容性实际功能委托给webhookService
}
/**
@@ -22,94 +18,40 @@ class WebhookNotifier {
* @param {string} notification.timestamp - 时间戳
*/
async sendAccountAnomalyNotification(notification) {
if (!this.enabled || this.webhookUrls.length === 0) {
logger.debug('Webhook notification disabled or no URLs configured')
return
}
const payload = {
type: 'account_anomaly',
data: {
try {
// 使用新的webhookService发送通知
await webhookService.sendNotification('accountAnomaly', {
accountId: notification.accountId,
accountName: notification.accountName,
platform: notification.platform,
status: notification.status,
errorCode: notification.errorCode,
errorCode:
notification.errorCode || this._getErrorCode(notification.platform, notification.status),
reason: notification.reason,
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'
}
timestamp: notification.timestamp || new Date().toISOString()
})
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) {
logger.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}`)
logger.error('Failed to send account anomaly notification:', error)
}
}
/**
* 测试Webhook连通性
* 测试Webhook连通性(兼容旧接口)
* @param {string} url - Webhook URL
* @param {string} type - 平台类型(可选)
*/
async testWebhook(url) {
const testPayload = {
type: 'test',
data: {
message: 'Claude Relay Service webhook test',
timestamp: new Date().toISOString(),
service: 'claude-relay-service'
}
}
async testWebhook(url, type = 'custom') {
try {
await this._sendWebhook(url, testPayload)
return { success: true }
// 创建临时平台配置
const platform = {
type,
url,
enabled: true,
timeout: 10000
}
const result = await webhookService.testWebhook(platform)
return result
} catch (error) {
return { success: false, error: error.message }
}

View File

@@ -18,12 +18,14 @@
"vue-router": "^4.2.5"
},
"devDependencies": {
"@playwright/test": "^1.55.0",
"@vitejs/plugin-vue": "^4.5.2",
"@vue/eslint-config-prettier": "^10.2.0",
"autoprefixer": "^10.4.16",
"eslint": "^8.55.0",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-vue": "^9.19.2",
"playwright": "^1.55.0",
"postcss": "^8.4.32",
"prettier": "^3.1.1",
"prettier-plugin-tailwindcss": "^0.6.14",
@@ -806,6 +808,22 @@
"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": {
"name": "@sxzz/popperjs-es",
"version": "2.11.7",
@@ -3465,6 +3483,53 @@
"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": {
"version": "8.5.6",
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.6.tgz",
@@ -3655,7 +3720,7 @@
},
"node_modules/prettier-plugin-tailwindcss": {
"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==",
"dev": true,
"license": "MIT",

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
/* 从原始 style.css 复制的全局样式 */
:root {
/* 亮色模式 */
--primary-color: #667eea;
--secondary-color: #764ba2;
--accent-color: #f093fb;
@@ -8,31 +9,62 @@
--error-color: #ef4444;
--surface-color: rgba(255, 255, 255, 0.95);
--glass-color: rgba(255, 255, 255, 0.1);
--glass-strong-color: rgba(255, 255, 255, 0.95);
--text-primary: #1f2937;
--text-secondary: #6b7280;
--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 - 仅应用于特定元素 */
body,
div,
.dark {
/* 暗黑模式 */
--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,
input,
select,
textarea,
table,
tr,
td,
th,
span,
p,
h1,
h2,
h3,
h4,
h5,
h6 {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
textarea {
transition:
background-color 0.3s ease,
border-color 0.3s ease,
box-shadow 0.3s ease,
transform 0.2s ease;
}
/* 颜色和背景过渡 */
.transition-colors {
transition:
color 0.3s ease,
background-color 0.3s ease,
border-color 0.3s ease;
}
body {
@@ -45,14 +77,18 @@ body {
sans-serif;
background: linear-gradient(
135deg,
var(--primary-color) 0%,
var(--secondary-color) 50%,
var(--accent-color) 100%
var(--bg-gradient-start) 0%,
var(--bg-gradient-mid) 50%,
var(--bg-gradient-end) 100%
);
background-attachment: fixed;
min-height: 100vh;
margin: 0;
overflow-x: hidden;
color: var(--text-primary);
transition:
background 0.3s ease,
color 0.3s ease;
}
body::before {
@@ -73,21 +109,43 @@ body::before {
.glass {
background: var(--glass-color);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--border-color);
box-shadow:
0 20px 25px -5px rgba(0, 0, 0, 0.1),
0 10px 10px -5px rgba(0, 0, 0, 0.04),
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 {
background: var(--surface-color);
background: var(--glass-strong-color);
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:
0 25px 50px -12px rgba(0, 0, 0, 0.25),
0 0 0 1px rgba(255, 255, 255, 0.05),
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 {
@@ -201,13 +259,18 @@ body::before {
/* 表单输入框样式 */
.form-input {
background: rgba(255, 255, 255, 0.9);
border: 2px solid rgba(255, 255, 255, 0.3);
background: var(--input-bg);
border: 2px solid var(--input-border);
border-radius: 12px;
padding: 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);
-webkit-backdrop-filter: blur(10px);
}
.form-input:focus {
@@ -217,17 +280,35 @@ body::before {
0 0 0 3px rgba(102, 126, 234, 0.1),
0 10px 15px -3px rgba(0, 0, 0, 0.1);
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 {
background: var(--surface-color);
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.2);
border: 1px solid var(--border-color);
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
overflow: hidden;
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 {
@@ -241,13 +322,19 @@ body::before {
}
.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: 1px solid rgba(255, 255, 255, 0.3);
border: 1px solid var(--border-color);
padding: 24px;
position: relative;
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 {
@@ -364,13 +451,18 @@ body::before {
}
.form-input {
background: rgba(255, 255, 255, 0.9);
border: 2px solid rgba(255, 255, 255, 0.3);
background: var(--input-bg);
border: 2px solid var(--input-border);
border-radius: 12px;
padding: 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);
-webkit-backdrop-filter: blur(10px);
}
.form-input:focus {
@@ -380,39 +472,71 @@ body::before {
0 0 0 3px rgba(102, 126, 234, 0.1),
0 10px 15px -3px rgba(0, 0, 0, 0.1);
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 {
background: rgba(255, 255, 255, 0.95);
background: var(--table-bg);
border-radius: 16px;
overflow: hidden;
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
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 {
transition: all 0.2s ease;
transition:
background-color 0.2s ease,
transform 0.2s ease;
}
.table-row:hover {
background: rgba(102, 126, 234, 0.05);
background: var(--table-hover);
transform: scale(1.005);
}
.modal {
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 {
background: rgba(255, 255, 255, 0.95);
background: white;
border-radius: 24px;
border: 1px solid rgba(255, 255, 255, 0.3);
border: 1px solid rgba(229, 231, 235, 0.8);
box-shadow:
0 25px 50px -12px rgba(0, 0, 0, 0.25),
0 0 0 1px rgba(255, 255, 255, 0.05);
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 {
@@ -517,6 +641,10 @@ body::before {
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 {
width: 8px;
height: 8px;
@@ -527,20 +655,36 @@ body::before {
border-radius: 10px;
}
.dark .custom-scrollbar::-webkit-scrollbar-track {
background: rgba(129, 140, 248, 0.05);
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: linear-gradient(135deg, rgba(102, 126, 234, 0.4) 0%, rgba(118, 75, 162, 0.4) 100%);
border-radius: 10px;
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 {
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 {
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 {
max-height: calc(90vh - 160px);

File diff suppressed because it is too large Load Diff

View File

@@ -172,7 +172,7 @@
<!-- 编辑分组模态框 -->
<div
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="mb-4 flex items-center justify-between">

View File

@@ -2,7 +2,9 @@
<div class="space-y-6">
<!-- Claude OAuth流程 -->
<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 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" />
</div>
<div class="flex-1">
<h4 class="mb-3 font-semibold text-blue-900">Claude 账户授权</h4>
<p class="mb-4 text-sm text-blue-800">请按照以下步骤完成 Claude 账户的授权</p>
<h4 class="mb-3 font-semibold text-blue-900 dark:text-blue-200">Claude 账户授权</h4>
<p class="mb-4 text-sm text-blue-800 dark:text-blue-300">
请按照以下步骤完成 Claude 账户的授权
</p>
<div class="space-y-4">
<!-- 步骤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 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
</div>
<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
v-if="!authUrl"
class="btn btn-primary px-4 py-2 text-sm"
@@ -37,13 +45,13 @@
<div v-else class="space-y-3">
<div class="flex items-center gap-2">
<input
class="form-input flex-1 bg-gray-50 font-mono text-xs"
class="form-input flex-1 bg-gray-50 font-mono text-xs dark:bg-gray-700"
readonly
type="text"
:value="authUrl"
/>
<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="复制链接"
@click="copyAuthUrl"
>
@@ -62,7 +70,9 @@
</div>
<!-- 步骤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 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
</div>
<div class="flex-1">
<p class="mb-2 font-medium text-blue-900">在浏览器中打开链接并完成授权</p>
<p class="mb-2 text-sm text-blue-700">
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
在浏览器中打开链接并完成授权
</p>
<p class="mb-2 text-sm text-blue-700 dark:text-blue-300">
请在新标签页中打开授权链接登录您的 Claude 账户并授权
</p>
<div class="rounded border border-yellow-300 bg-yellow-50 p-3">
<p class="text-xs text-yellow-800">
<div
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" />
<strong>注意</strong
>如果您设置了代理请确保浏览器也使用相同的代理访问授权页面
@@ -86,7 +100,9 @@
</div>
<!-- 步骤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 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
</div>
<div class="flex-1">
<p class="mb-2 font-medium text-blue-900">输入 Authorization Code</p>
<p class="mb-3 text-sm text-blue-700">
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
输入 Authorization Code
</p>
<p class="mb-3 text-sm text-blue-700 dark:text-blue-300">
授权完成后页面会显示一个
<strong>Authorization Code</strong>请将其复制并粘贴到下方输入框
</p>
<div class="space-y-3">
<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
</label>
<textarea
@@ -111,7 +131,7 @@
rows="3"
/>
</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" />
请粘贴从Claude页面复制的Authorization Code
</p>
@@ -127,7 +147,9 @@
<!-- Gemini OAuth流程 -->
<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 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" />
</div>
<div class="flex-1">
<h4 class="mb-3 font-semibold text-green-900">Gemini 账户授权</h4>
<p class="mb-4 text-sm text-green-800">请按照以下步骤完成 Gemini 账户的授权</p>
<h4 class="mb-3 font-semibold text-green-900 dark:text-green-200">Gemini 账户授权</h4>
<p class="mb-4 text-sm text-green-800 dark:text-green-300">
请按照以下步骤完成 Gemini 账户的授权
</p>
<div class="space-y-4">
<!-- 步骤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 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
</div>
<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
v-if="!authUrl"
class="btn btn-primary px-4 py-2 text-sm"
@@ -162,13 +190,13 @@
<div v-else class="space-y-3">
<div class="flex items-center gap-2">
<input
class="form-input flex-1 bg-gray-50 font-mono text-xs"
class="form-input flex-1 bg-gray-50 font-mono text-xs dark:bg-gray-700"
readonly
type="text"
:value="authUrl"
/>
<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="复制链接"
@click="copyAuthUrl"
>
@@ -187,7 +215,9 @@
</div>
<!-- 步骤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 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
</div>
<div class="flex-1">
<p class="mb-2 font-medium text-blue-900">在浏览器中打开链接并完成授权</p>
<p class="mb-2 text-sm text-blue-700">
<p class="mb-2 font-medium text-green-900 dark:text-green-200">
在浏览器中打开链接并完成授权
</p>
<p class="mb-2 text-sm text-green-700 dark:text-green-300">
请在新标签页中打开授权链接登录您的 Gemini 账户并授权
</p>
<div class="rounded border border-yellow-300 bg-yellow-50 p-3">
<p class="text-xs text-yellow-800">
<div
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" />
<strong>注意</strong
>如果您设置了代理请确保浏览器也使用相同的代理访问授权页面
@@ -211,7 +245,9 @@
</div>
<!-- 步骤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 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
</div>
<div class="flex-1">
<p class="mb-2 font-medium text-green-900">输入 Authorization Code</p>
<p class="mb-3 text-sm text-green-700">
<p class="mb-2 font-medium text-green-900 dark:text-green-200">
输入 Authorization Code
</p>
<p class="mb-3 text-sm text-green-700 dark:text-green-300">
授权完成后页面会显示一个 Authorization Code请将其复制并粘贴到下方输入框
</p>
<div class="space-y-3">
<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
</label>
<textarea
@@ -236,7 +276,7 @@
/>
</div>
<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" />
请粘贴从Gemini页面复制的Authorization Code
</p>
@@ -253,7 +293,9 @@
<!-- OpenAI OAuth流程 -->
<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 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" />
</div>
<div class="flex-1">
<h4 class="mb-3 font-semibold text-orange-900">OpenAI 账户授权</h4>
<p class="mb-4 text-sm text-orange-800">请按照以下步骤完成 OpenAI 账户的授权</p>
<h4 class="mb-3 font-semibold text-orange-900 dark:text-orange-200">OpenAI 账户授权</h4>
<p class="mb-4 text-sm text-orange-800 dark:text-orange-300">
请按照以下步骤完成 OpenAI 账户的授权
</p>
<div class="space-y-4">
<!-- 步骤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 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
</div>
<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
v-if="!authUrl"
class="btn btn-primary px-4 py-2 text-sm"
@@ -288,13 +336,13 @@
<div v-else class="space-y-3">
<div class="flex items-center gap-2">
<input
class="form-input flex-1 bg-gray-50 font-mono text-xs"
class="form-input flex-1 bg-gray-50 font-mono text-xs dark:bg-gray-700"
readonly
type="text"
:value="authUrl"
/>
<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="复制链接"
@click="copyAuthUrl"
>
@@ -313,7 +361,9 @@
</div>
<!-- 步骤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 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
</div>
<div class="flex-1">
<p class="mb-2 font-medium text-orange-900">在浏览器中打开链接并完成授权</p>
<p class="mb-2 text-sm text-orange-700">
<p class="mb-2 font-medium text-orange-900 dark:text-orange-200">
在浏览器中打开链接并完成授权
</p>
<p class="mb-2 text-sm text-orange-700 dark:text-orange-300">
请在新标签页中打开授权链接登录您的 OpenAI 账户并授权
</p>
<div class="mb-3 rounded border border-amber-300 bg-amber-50 p-3">
<p class="text-xs text-amber-800">
<div
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" />
<strong>重要提示</strong>授权后页面可能会加载较长时间请耐心等待
</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>
开头时表示授权已完成
</p>
</div>
<div class="rounded border border-yellow-300 bg-yellow-50 p-3">
<p class="text-xs text-yellow-800">
<div
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" />
<strong>注意</strong
>如果您设置了代理请确保浏览器也使用相同的代理访问授权页面
@@ -348,7 +404,9 @@
</div>
<!-- 步骤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 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
</div>
<div class="flex-1">
<p class="mb-2 font-medium text-orange-900">输入授权链接或 Code</p>
<p class="mb-3 text-sm text-orange-700">
<p class="mb-2 font-medium text-orange-900 dark:text-orange-200">
输入授权链接或 Code
</p>
<p class="mb-3 text-sm text-orange-700 dark:text-orange-300">
授权完成后当页面地址变为
<strong class="font-mono">http://localhost:1455/...</strong> 时:
</p>
<div class="space-y-3">
<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
</label>
<textarea
@@ -373,13 +435,15 @@
rows="3"
/>
</div>
<div class="rounded border border-blue-300 bg-blue-50 p-2">
<p class="text-xs text-blue-700">
<div
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" />
<strong>提示</strong>您可以直接复制整个链接或仅复制 code
参数值系统会自动识别
</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"
>http://localhost:1455/auth/callback?code=ac_4hm8...</span
>
@@ -402,7 +466,7 @@
<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"
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"
@click="$emit('back')"
>

View File

@@ -1,35 +1,43 @@
<template>
<div class="space-y-4">
<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">
<input
v-model="proxy.enabled"
class="h-4 w-4 rounded border-gray-300 bg-gray-100 text-blue-600 focus:ring-blue-500"
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>
</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="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" />
</div>
<div class="flex-1">
<p class="text-sm text-gray-700">
<p class="text-sm text-gray-700 dark:text-gray-300">
配置代理以访问受限的网络资源支持 SOCKS5 HTTP 代理
</p>
<p class="mt-1 text-xs text-gray-500">
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
请确保代理服务器稳定可用否则会影响账户的正常使用
</p>
</div>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-700">代理类型</label>
<select v-model="proxy.type" class="form-input w-full">
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>代理类型</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="http">HTTP</option>
<option value="https">HTTPS</option>
@@ -38,19 +46,23 @@
<div class="grid grid-cols-2 gap-4">
<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
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"
type="text"
/>
</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
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"
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"
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>
</div>
<div v-if="showAuth" class="grid grid-cols-2 gap-4">
<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
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="代理用户名"
type="text"
/>
</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">
<input
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="代理密码"
:type="showPassword ? 'text' : 'password'"
/>
<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"
@click="showPassword = !showPassword"
>
@@ -101,8 +120,10 @@
</div>
</div>
<div class="rounded-lg border border-blue-200 bg-blue-50 p-3">
<p class="text-xs text-blue-700">
<div
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" />
<strong>提示</strong
>代理设置将用于所有与此账户相关的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" />
</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>
<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')"
>
<i class="fas fa-times text-lg sm:text-xl" />
@@ -25,7 +27,7 @@
>
<!-- 创建类型选择 -->
<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
:class="[
@@ -33,18 +35,21 @@
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
>
<div class="flex items-center gap-3 sm:gap-4">
<label class="flex cursor-pointer items-center">
<input
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"
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" />
单个创建
</span>
@@ -52,11 +57,13 @@
<label class="flex cursor-pointer items-center">
<input
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"
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" />
批量创建
</span>
@@ -68,22 +75,26 @@
<div v-if="form.createType === 'batch'" class="mt-3">
<div class="flex items-center gap-4">
<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">
<input
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"
min="2"
placeholder="输入数量 (2-500)"
required
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>
<p class="mt-2 flex items-start text-xs text-amber-600">
<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" />
<span
>批量创建时每个 Key 的名称会自动添加序号后缀例如{{
@@ -95,12 +106,13 @@
</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
>
<input
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 }"
:placeholder="
form.createType === 'batch'
@@ -111,27 +123,31 @@
type="text"
@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 }}
</p>
</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 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">
<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"
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 }}
<button
class="ml-1 hover:text-blue-900"
class="ml-1 hover:text-blue-900 dark:hover:text-blue-300"
type="button"
@click="removeTag(index)"
>
@@ -143,16 +159,18 @@
<!-- 可选择的已有标签 -->
<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">
<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"
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"
@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 }}
</button>
</div>
@@ -160,11 +178,13 @@
<!-- 创建新标签 -->
<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">
<input
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="输入新标签名称"
type="text"
@keypress.enter.prevent="addTag"
@@ -179,65 +199,79 @@
</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 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="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">速率限制设置 (可选)</h4>
<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"
<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"
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"
placeholder="无限制"
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>
<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
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"
placeholder="无限制"
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>
<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
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="无限制"
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 class="rounded-lg bg-blue-100 p-2">
<h5 class="mb-1 text-xs font-semibold text-blue-800">💡 使用示例</h5>
<div class="space-y-0.5 text-xs text-blue-700">
<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 dark:text-blue-400">
💡 使用示例
</h5>
<div class="space-y-0.5 text-xs text-blue-700 dark:text-blue-300">
<div>
<strong>示例1:</strong> 时间窗口=60请求次数=1000 每60分钟最多1000次请求
</div>
@@ -254,34 +288,34 @@
</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
>
<div class="space-y-2">
<div class="flex gap-2">
<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"
@click="form.dailyCostLimit = '50'"
>
$50
</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"
@click="form.dailyCostLimit = '100'"
>
$100
</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"
@click="form.dailyCostLimit = '200'"
>
$200
</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"
@click="form.dailyCostLimit = ''"
>
@@ -290,47 +324,53 @@
</div>
<input
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"
placeholder="0 表示无限制"
step="0.01"
type="number"
/>
<p class="text-xs text-gray-500">
<p class="text-xs text-gray-500 dark:text-gray-400">
设置此 API Key 每日的费用限制超过限制将拒绝请求0 或留空表示无限制
</p>
</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
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"
placeholder="0 表示无限制"
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 或留空表示无限制
</p>
</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
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 的用途..."
rows="2"
/>
</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
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"
>
<option value="">永不过期</option>
@@ -345,45 +385,71 @@
<div v-if="form.expireDuration === 'custom'" class="mt-3">
<input
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"
type="datetime-local"
@change="updateCustomExpireAt"
/>
</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) }} 过期
</p>
</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">
<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>
<input
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 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>
<input
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 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>
<input
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 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>
<input
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>
</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 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
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"
title="刷新账号列表"
type="button"
@@ -401,7 +467,9 @@
</div>
<div class="grid grid-cols-1 gap-3">
<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
v-model="form.claudeAccountId"
:accounts="localAccounts.claude"
@@ -413,7 +481,9 @@
/>
</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
v-model="form.geminiAccountId"
:accounts="localAccounts.gemini"
@@ -425,7 +495,9 @@
/>
</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
v-model="form.openaiAccountId"
:accounts="localAccounts.openai"
@@ -437,7 +509,9 @@
/>
</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
v-model="form.bedrockAccountId"
:accounts="localAccounts.bedrock"
@@ -449,7 +523,7 @@
/>
</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将只使用该账号不选择则使用共享账号池
</p>
</div>
@@ -463,7 +537,7 @@
type="checkbox"
/>
<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"
>
启用模型限制
@@ -549,7 +623,7 @@
type="checkbox"
/>
<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"
>
启用客户端限制
@@ -558,10 +632,12 @@
<div
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>
<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 v-for="client in supportedClients" :key="client.id" class="flex items-start">
<input
@@ -572,8 +648,12 @@
:value="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="block text-xs text-gray-500">{{ client.description }}</span>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{
client.name
}}</span>
<span class="block text-xs text-gray-500 dark:text-gray-400">{{
client.description
}}</span>
</label>
</div>
</div>
@@ -583,7 +663,7 @@
<div class="flex gap-3 pt-2">
<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"
@click="$emit('close')"
>

View File

@@ -11,10 +11,12 @@
>
<i class="fas fa-edit text-sm text-white sm:text-base" />
</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>
<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')"
>
<i class="fas fa-times text-lg sm:text-xl" />
@@ -26,36 +28,40 @@
@submit.prevent="updateApiKey"
>
<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
>
<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
type="text"
: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>
<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
>
<div class="space-y-4">
<!-- 已选择的标签 -->
<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">
<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"
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 }}
<button
class="ml-1 hover:text-blue-900"
class="ml-1 hover:text-blue-900 dark:hover:text-blue-300"
type="button"
@click="removeTag(index)"
>
@@ -67,16 +73,18 @@
<!-- 可选择的已有标签 -->
<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">
<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"
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"
@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 }}
</button>
</div>
@@ -84,11 +92,13 @@
<!-- 创建新标签 -->
<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">
<input
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="输入新标签名称"
type="text"
@keypress.enter.prevent="addTag"
@@ -103,65 +113,79 @@
</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 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="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">速率限制设置 (可选)</h4>
<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"
<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"
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"
placeholder="无限制"
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>
<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
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"
placeholder="无限制"
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>
<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
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="无限制"
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 class="rounded-lg bg-blue-100 p-2">
<h5 class="mb-1 text-xs font-semibold text-blue-800">💡 使用示例</h5>
<div class="space-y-0.5 text-xs text-blue-700">
<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 dark:text-blue-400">
💡 使用示例
</h5>
<div class="space-y-0.5 text-xs text-blue-700 dark:text-blue-300">
<div>
<strong>示例1:</strong> 时间窗口=60请求次数=1000 每60分钟最多1000次请求
</div>
@@ -178,34 +202,34 @@
</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
>
<div class="space-y-3">
<div class="flex gap-2">
<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"
@click="form.dailyCostLimit = '50'"
>
$50
</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"
@click="form.dailyCostLimit = '100'"
>
$100
</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"
@click="form.dailyCostLimit = '200'"
>
$200
</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"
@click="form.dailyCostLimit = ''"
>
@@ -214,28 +238,32 @@
</div>
<input
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"
placeholder="0 表示无限制"
step="0.01"
type="number"
/>
<p class="text-xs text-gray-500">
<p class="text-xs text-gray-500 dark:text-gray-400">
设置此 API Key 每日的费用限制超过限制将拒绝请求0 或留空表示无限制
</p>
</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
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"
placeholder="0 表示无限制"
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>
<!-- 激活账号 -->
@@ -244,49 +272,75 @@
<input
id="editIsActive"
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"
/>
<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"
>
激活账号
</label>
</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 错误
</p>
</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">
<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>
<input
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 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>
<input
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 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>
<input
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 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>
<input
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>
</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 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
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"
title="刷新账号列表"
type="button"
@@ -304,7 +358,9 @@
</div>
<div class="grid grid-cols-1 gap-3">
<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
v-model="form.claudeAccountId"
:accounts="localAccounts.claude"
@@ -316,7 +372,9 @@
/>
</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
v-model="form.geminiAccountId"
:accounts="localAccounts.gemini"
@@ -328,7 +386,9 @@
/>
</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
v-model="form.openaiAccountId"
:accounts="localAccounts.openai"
@@ -340,7 +400,9 @@
/>
</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
v-model="form.bedrockAccountId"
:accounts="localAccounts.bedrock"
@@ -352,7 +414,9 @@
/>
</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>
@@ -360,11 +424,11 @@
<input
id="editEnableModelRestriction"
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"
/>
<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"
>
启用模型限制
@@ -373,25 +437,30 @@
<div v-if="form.enableModelRestriction" class="space-y-3">
<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
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
v-for="(model, index) in form.restrictedModels"
: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 }}
<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"
@click="removeRestrictedModel(index)"
>
<i class="fas fa-times text-xs" />
</button>
</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>
</div>
@@ -401,7 +470,7 @@
<button
v-for="model in availableQuickModels"
: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"
@click="quickAddRestrictedModel(model)"
>
@@ -409,7 +478,7 @@
</button>
<span
v-if="availableQuickModels.length === 0"
class="text-sm italic text-gray-400"
class="text-sm italic text-gray-400 dark:text-gray-500"
>
所有常用模型已在限制列表中
</span>
@@ -419,7 +488,7 @@
<div class="flex gap-2">
<input
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="输入模型名称,按回车添加"
type="text"
@keydown.enter.prevent="addRestrictedModel"
@@ -433,7 +502,7 @@
</button>
</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
</p>
</div>
@@ -446,11 +515,11 @@
<input
id="editEnableClientRestriction"
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"
/>
<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"
>
启用客户端限制
@@ -459,8 +528,12 @@
<div v-if="form.enableClientRestriction" class="space-y-3">
<div>
<label class="mb-2 block text-sm font-medium text-gray-600">允许的客户端</label>
<p class="mb-3 text-xs text-gray-500">勾选允许使用此API Key的客户端</p>
<label class="mb-2 block text-sm font-medium text-gray-600 dark:text-gray-400"
>允许的客户端</label
>
<p class="mb-3 text-xs text-gray-500 dark:text-gray-400">
勾选允许使用此API Key的客户端
</p>
<div class="space-y-2">
<div v-for="client in supportedClients" :key="client.id" class="flex items-start">
<input
@@ -471,8 +544,12 @@
:value="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="block text-xs text-gray-500">{{ client.description }}</span>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{
client.name
}}</span>
<span class="block text-xs text-gray-500 dark:text-gray-400">{{
client.description
}}</span>
</label>
</div>
</div>
@@ -482,7 +559,7 @@
<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"
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')"
>

View File

@@ -1,8 +1,14 @@
<template>
<Teleport to="body">
<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="flex items-center gap-3">
@@ -12,14 +18,14 @@
<i class="fas fa-clock text-white" />
</div>
<div>
<h3 class="text-xl font-bold text-gray-900">修改过期时间</h3>
<p class="text-sm text-gray-600">
<h3 class="text-xl font-bold text-gray-900 dark:text-gray-100">修改过期时间</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
"{{ apiKey.name || 'API Key' }}" 设置新的过期时间
</p>
</div>
</div>
<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')"
>
<i class="fas fa-times text-xl" />
@@ -29,12 +35,14 @@
<div class="space-y-6">
<!-- 当前状态显示 -->
<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>
<p class="mb-1 text-xs font-medium text-gray-600">当前过期时间</p>
<p class="text-sm font-semibold text-gray-800">
<p class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-400">
当前过期时间
</p>
<p class="text-sm font-semibold text-gray-800 dark:text-gray-200">
<template v-if="apiKey.expiresAt">
{{ formatExpireDate(apiKey.expiresAt) }}
<span
@@ -51,7 +59,9 @@
</template>
</p>
</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-hourglass-half text-lg',
@@ -66,7 +76,9 @@
<!-- 快捷选项 -->
<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">
<button
v-for="option in quickOptions"
@@ -75,7 +87,7 @@
'rounded-lg px-3 py-2 text-sm font-medium transition-all',
localForm.expireDuration === option.value
? '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)"
>
@@ -86,7 +98,7 @@
'rounded-lg px-3 py-2 text-sm font-medium transition-all',
localForm.expireDuration === 'custom'
? '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')"
>
@@ -98,29 +110,33 @@
<!-- 自定义日期选择 -->
<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
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"
type="datetime-local"
@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
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>
<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" />
新的过期时间
</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">
{{ formatExpireDate(localForm.expiresAt) }}
<span
@@ -137,7 +153,9 @@
</template>
</p>
</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" />
</div>
</div>
@@ -146,7 +164,7 @@
<!-- 操作按钮 -->
<div class="flex gap-3 pt-2">
<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')"
>
取消

View File

@@ -12,12 +12,12 @@
<i class="fas fa-check text-lg text-white" />
</div>
<div>
<h3 class="text-xl font-bold text-gray-900">API Key 创建成功</h3>
<p class="text-sm text-gray-600">请妥善保存您的 API Key</p>
<h3 class="text-xl font-bold text-gray-900 dark:text-gray-100">API Key 创建成功</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">请妥善保存您的 API Key</p>
</div>
</div>
<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="直接关闭(不推荐)"
@click="handleDirectClose"
>
@@ -26,16 +26,18 @@
</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="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" />
</div>
<div class="ml-3">
<h5 class="mb-1 font-semibold text-amber-900">重要提醒</h5>
<p class="text-sm text-amber-800">
<h5 class="mb-1 font-semibold text-amber-900 dark:text-amber-400">重要提醒</h5>
<p class="text-sm text-amber-800 dark:text-amber-300">
这是您唯一能看到完整 API Key 的机会关闭此窗口后系统将不再显示完整的 API
Key请立即复制并妥善保存
</p>
@@ -46,30 +48,42 @@
<!-- API Key 信息 -->
<div class="mb-6 space-y-4">
<div>
<label class="mb-2 block text-sm font-semibold text-gray-700">API Key 名称</label>
<div class="rounded-lg border bg-gray-50 p-3">
<span class="font-medium text-gray-900">{{ apiKey.name }}</span>
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>API Key 名称</label
>
<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 v-if="apiKey.description">
<label class="mb-2 block text-sm font-semibold text-gray-700">备注</label>
<div class="rounded-lg border bg-gray-50 p-3">
<span class="text-gray-700">{{ apiKey.description || '无描述' }}</span>
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>备注</label
>
<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>
<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="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() }}
</div>
<div class="absolute right-3 top-3">
<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'"
type="button"
@click="toggleKeyVisibility"
@@ -78,7 +92,7 @@
</button>
</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
</p>
</div>
@@ -94,7 +108,7 @@
复制 API Key
</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"
>
我已保存

View File

@@ -16,7 +16,7 @@
>
<i class="fas fa-chart-line text-sm text-white sm:text-base" />
</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 }}
</h3>
</div>
@@ -31,64 +31,68 @@
<div class="mb-6 grid grid-cols-1 gap-4 md:grid-cols-2">
<!-- 请求统计卡片 -->
<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">
<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" />
</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) }}
</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) }}
</div>
</div>
<!-- Token统计卡片 -->
<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">
<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" />
</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) }}
</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) }}
</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">
<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" />
</div>
<div class="text-2xl font-bold text-gray-900">${{ totalCost.toFixed(4) }}</div>
<div class="mt-1 text-xs text-gray-600">今日: ${{ dailyCost.toFixed(4) }}</div>
<div class="text-2xl font-bold text-gray-900 dark:text-gray-100">
${{ totalCost.toFixed(4) }}
</div>
<div class="mt-1 text-xs text-gray-600 dark:text-gray-400">
今日: ${{ dailyCost.toFixed(4) }}
</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">
<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" />
</div>
<div class="space-y-1 text-sm">
<div class="flex justify-between">
<span class="text-gray-600">RPM:</span>
<span class="font-semibold">{{ rpm }}</span>
<span class="text-gray-600 dark:text-gray-400">RPM:</span>
<span class="font-semibold text-gray-900 dark:text-gray-100">{{ rpm }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">TPM:</span>
<span class="font-semibold">{{ tpm }}</span>
<span class="text-gray-600 dark:text-gray-400">TPM:</span>
<span class="font-semibold text-gray-900 dark:text-gray-100">{{ tpm }}</span>
</div>
</div>
</div>
@@ -96,33 +100,35 @@
<!-- Token详细分布 -->
<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" />
Token 使用分布
</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">
<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>
<span class="text-sm font-semibold text-gray-900">
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ formatTokenCount(inputTokens) }}
</span>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center">
<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>
<span class="text-sm font-semibold text-gray-900">
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ formatTokenCount(outputTokens) }}
</span>
</div>
<div v-if="cacheCreateTokens > 0" class="flex items-center justify-between">
<div class="flex items-center">
<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>
<span class="text-sm font-semibold text-purple-600">
{{ formatTokenCount(cacheCreateTokens) }}
@@ -131,7 +137,7 @@
<div v-if="cacheReadTokens > 0" class="flex items-center justify-between">
<div class="flex items-center">
<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>
<span class="text-sm font-semibold text-purple-600">
{{ formatTokenCount(cacheReadTokens) }}
@@ -142,19 +148,21 @@
<!-- 限制信息 -->
<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" />
限制设置
</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 class="flex items-center justify-between text-sm">
<span class="text-gray-600">每日费用限制</span>
<span class="font-semibold text-gray-900">
<span class="text-gray-600 dark:text-gray-400">每日费用限制</span>
<span class="font-semibold text-gray-900 dark:text-gray-100">
${{ apiKey.dailyCostLimit.toFixed(2) }}
</span>
</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
class="h-2 rounded-full transition-all duration-300"
:class="
@@ -167,7 +175,7 @@
:style="{ width: Math.min(dailyCostPercentage, 100) + '%' }"
/>
</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) }}%
</div>
</div>
@@ -176,14 +184,14 @@
v-if="apiKey.concurrencyLimit > 0"
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">
{{ apiKey.currentConcurrency || 0 }} / {{ apiKey.concurrencyLimit }}
</span>
</div>
<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" />
时间窗口限制
</h5>

View File

@@ -1,12 +1,12 @@
<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">
<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" />
使用统计查询
</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>
<!-- 输入区域 -->
@@ -14,7 +14,7 @@
<div class="api-input-grid grid grid-cols-1 lg:grid-cols-4">
<!-- API Key 输入 -->
<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" />
输入您的 API Key
</label>
@@ -30,7 +30,9 @@
<!-- 查询按钮 -->
<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
class="btn btn-primary btn-query flex h-full w-full items-center justify-center gap-2"
:disabled="loading || !apiKey.trim()"
@@ -62,11 +64,11 @@ const { queryStats } = apiStatsStore
</script>
<style scoped>
/* 宽卡片样式 */
/* 宽卡片样式 - 使用CSS变量 */
.api-input-wide-card {
background: rgba(255, 255, 255, 0.95);
background: var(--surface-color);
backdrop-filter: blur(25px);
border: 1px solid rgba(255, 255, 255, 0.3);
border: 1px solid var(--border-color);
box-shadow:
0 25px 50px -12px rgba(0, 0, 0, 0.25),
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);
}
/* 暗夜模式宽卡片样式 */
: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 {
box-shadow:
0 32px 64px -12px rgba(0, 0, 0, 0.35),
@@ -82,6 +92,13 @@ const { queryStats } = apiStatsStore
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 {
color: #1f2937;
@@ -89,11 +106,21 @@ const { queryStats } = apiStatsStore
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 {
color: #4b5563;
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 {
color: #3b82f6;
text-shadow: 0 1px 2px rgba(59, 130, 246, 0.2);
@@ -105,23 +132,32 @@ const { queryStats } = apiStatsStore
gap: 1rem;
}
/* 输入框样式 */
/* 输入框样式 - 使用CSS变量 */
.wide-card-input {
background: rgba(255, 255, 255, 0.95);
border: 2px solid rgba(255, 255, 255, 0.4);
background: var(--input-bg);
border: 2px solid var(--input-border);
border-radius: 12px;
padding: 14px 16px;
font-size: 16px;
transition: all 0.3s ease;
backdrop-filter: blur(10px);
color: #1f2937;
color: var(--text-primary);
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 {
color: #9ca3af;
}
:global(.dark) .wide-card-input::placeholder {
color: #64748b;
}
.wide-card-input:focus {
outline: none;
border-color: #60a5fa;
@@ -129,6 +165,16 @@ const { queryStats } = apiStatsStore
0 0 0 3px rgba(96, 165, 250, 0.2),
0 10px 15px -3px rgba(0, 0, 0, 0.1);
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 {
background: rgba(255, 255, 255, 0.15);
border: 1px solid rgba(255, 255, 255, 0.25);
background: rgba(255, 255, 255, 0.5);
border: 1px solid rgba(255, 255, 255, 0.4);
backdrop-filter: blur(10px);
border-radius: 8px;
padding: 12px 16px;
@@ -182,9 +228,22 @@ const { queryStats } = apiStatsStore
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 {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.35);
background: rgba(255, 255, 255, 0.6);
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 {

View File

@@ -2,7 +2,9 @@
<div>
<!-- 限制配置 -->
<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" />
限制配置
</h3>
@@ -10,8 +12,10 @@
<!-- 每日费用限制 -->
<div>
<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-xs text-gray-500 md:text-sm">
<span class="text-sm font-medium text-gray-600 dark:text-gray-400 md:text-base"
>每日费用限制</span
>
<span class="text-xs text-gray-500 dark:text-gray-400 md:text-sm">
<span v-if="statsData.limits.dailyCostLimit > 0">
${{ statsData.limits.currentDailyCost.toFixed(4) }} / ${{
statsData.limits.dailyCostLimit.toFixed(2)
@@ -24,7 +28,7 @@
</div>
<div
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
class="h-2 rounded-full transition-all duration-300"
@@ -58,16 +62,16 @@
: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" />
请求次数和Token使用量为"或"的关系,任一达到限制即触发限流
</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">
<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 v-if="statsData.limits.concurrencyLimit > 0">
{{ statsData.limits.concurrencyLimit }}
@@ -78,7 +82,7 @@
</span>
</div>
<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
v-if="
@@ -97,7 +101,7 @@
</span>
</div>
<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
v-if="
@@ -129,7 +133,9 @@
"
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" />
详细限制信息
</h3>
@@ -141,9 +147,11 @@
statsData.restrictions.enableModelRestriction &&
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 md:mb-3 md:text-base">
<h4
class="mb-2 flex items-center text-sm font-bold text-amber-800 dark:text-amber-300 md:mb-3 md:text-base"
>
<i class="fas fa-robot mr-1 text-xs md:mr-2 md:text-sm" />
受限模型列表
</h4>
@@ -151,13 +159,13 @@
<div
v-for="model in statsData.restrictions.restrictedModels"
: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" />
<span class="break-all text-gray-800">{{ model }}</span>
<span class="break-all text-gray-800 dark:text-gray-200">{{ model }}</span>
</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" />
此 API Key 不能访问以上列出的模型
</p>
@@ -169,9 +177,11 @@
statsData.restrictions.enableClientRestriction &&
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 md:mb-3 md:text-base">
<h4
class="mb-2 flex items-center text-sm font-bold text-blue-800 dark:text-blue-300 md:mb-3 md:text-base"
>
<i class="fas fa-desktop mr-1 text-xs md:mr-2 md:text-sm" />
允许的客户端
</h4>
@@ -179,13 +189,13 @@
<div
v-for="client in statsData.restrictions.allowedClients"
: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" />
<span class="break-all text-gray-800">{{ client }}</span>
<span class="break-all text-gray-800 dark:text-gray-200">{{ client }}</span>
</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" />
API Key 只能被以上列出的客户端使用
</p>
@@ -222,11 +232,11 @@ const getDailyCostProgressColor = () => {
</script>
<style scoped>
/* 卡片样式 */
/* 卡片样式 - 使用CSS变量 */
.card {
background: rgba(255, 255, 255, 0.95);
background: var(--surface-color);
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.2);
border: 1px solid var(--border-color);
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
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 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>

View File

@@ -2,13 +2,13 @@
<div class="card p-4 md:p-6">
<div class="mb-4 md:mb-6">
<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">
<i class="fas fa-robot mr-2 text-sm text-indigo-500 md:mr-3 md:text-base" />
模型使用统计
</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
>
</h3>
@@ -16,8 +16,10 @@
<!-- 模型统计加载状态 -->
<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" />
<p class="text-sm text-gray-600 md:text-base">加载模型统计数据中...</p>
<i
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>
<!-- 模型统计数据 -->
@@ -25,41 +27,43 @@
<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="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 }}
</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 class="ml-3 flex-shrink-0 text-right">
<div class="text-base font-bold text-green-600 md:text-lg">
{{ model.formatted?.total || '$0.000000' }}
</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 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="text-gray-600">输入 Token</div>
<div class="font-medium text-gray-900">
<div class="rounded bg-gray-50 p-2 dark:bg-gray-700">
<div class="text-gray-600 dark:text-gray-400">输入 Token</div>
<div class="font-medium text-gray-900 dark:text-gray-100">
{{ formatNumber(model.inputTokens) }}
</div>
</div>
<div class="rounded bg-gray-50 p-2">
<div class="text-gray-600">输出 Token</div>
<div class="font-medium text-gray-900">
<div class="rounded bg-gray-50 p-2 dark:bg-gray-700">
<div class="text-gray-600 dark:text-gray-400">输出 Token</div>
<div class="font-medium text-gray-900 dark:text-gray-100">
{{ formatNumber(model.outputTokens) }}
</div>
</div>
<div class="rounded bg-gray-50 p-2">
<div class="text-gray-600">缓存创建</div>
<div class="font-medium text-gray-900">
<div class="rounded bg-gray-50 p-2 dark:bg-gray-700">
<div class="text-gray-600 dark:text-gray-400">缓存创建</div>
<div class="font-medium text-gray-900 dark:text-gray-100">
{{ formatNumber(model.cacheCreateTokens) }}
</div>
</div>
<div class="rounded bg-gray-50 p-2">
<div class="text-gray-600">缓存读取</div>
<div class="font-medium text-gray-900">
<div class="rounded bg-gray-50 p-2 dark:bg-gray-700">
<div class="text-gray-600 dark:text-gray-400">缓存读取</div>
<div class="font-medium text-gray-900 dark:text-gray-100">
{{ formatNumber(model.cacheReadTokens) }}
</div>
</div>
@@ -68,7 +72,7 @@
</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" />
<p class="text-sm md:text-base">
暂无{{ statsPeriod === 'daily' ? '今日' : '本月' }}模型使用数据
@@ -104,11 +108,11 @@ const formatNumber = (num) => {
</script>
<style scoped>
/* 卡片样式 */
/* 卡片样式 - 使用CSS变量 */
.card {
background: rgba(255, 255, 255, 0.95);
background: var(--surface-color);
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.2);
border: 1px solid var(--border-color);
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
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);
}
/* 模型使用项样式 */
: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 {
background: rgba(255, 255, 255, 0.95);
border: 1px solid rgba(255, 255, 255, 0.2);
background: var(--surface-color);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 12px;
transition: all 0.3s ease;
@@ -169,6 +179,13 @@ const formatNumber = (num) => {
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 {
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">
<!-- API Key 基本信息 -->
<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" />
API Key 信息
</h3>
<div class="space-y-2 md:space-y-3">
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600 md:text-base">名称</span>
<span class="break-all text-sm font-medium text-gray-900 md:text-base">{{
statsData.name
}}</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 dark:text-gray-100 md:text-base"
>{{ statsData.name }}</span
>
</div>
<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 md:text-base"
:class="statsData.isActive ? 'text-green-600' : 'text-red-600'"
@@ -27,19 +30,22 @@
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600 md:text-base">权限</span>
<span class="text-sm font-medium text-gray-900 md:text-base">{{
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">权限</span>
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 md:text-base">{{
formatPermissions(statsData.permissions)
}}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600 md:text-base">创建时间</span>
<span class="break-all text-xs font-medium text-gray-900 md:text-base">{{
formatDate(statsData.createdAt)
}}</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 dark:text-gray-100 md:text-base"
>{{ formatDate(statsData.createdAt) }}</span
>
</div>
<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="isApiKeyExpired(statsData.expiresAt)"
@@ -55,11 +61,14 @@
<i class="fas fa-clock mr-1 text-xs md:text-sm" />
{{ formatExpireDate(statsData.expiresAt) }}
</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) }}
</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" />
永不过期
</div>
@@ -70,13 +79,13 @@
<!-- 使用统计概览 -->
<div class="card p-4 md:p-6">
<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">
<i class="fas fa-chart-bar mr-2 text-sm text-green-500 md:mr-3 md:text-base" />
使用统计概览
</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
>
</h3>
@@ -85,7 +94,7 @@
<div class="text-lg font-bold text-green-600 md:text-3xl">
{{ formatNumber(currentPeriodData.requests) }}
</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' ? '今日' : '本月' }}请求数
</div>
</div>
@@ -93,7 +102,7 @@
<div class="text-lg font-bold text-blue-600 md:text-3xl">
{{ formatNumber(currentPeriodData.allTokens) }}
</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数
</div>
</div>
@@ -101,7 +110,7 @@
<div class="text-lg font-bold text-purple-600 md:text-3xl">
{{ currentPeriodData.formattedCost || '$0.000000' }}
</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' ? '今日' : '本月' }}费用
</div>
</div>
@@ -109,7 +118,7 @@
<div class="text-lg font-bold text-yellow-600 md:text-3xl">
{{ formatNumber(currentPeriodData.inputTokens) }}
</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
</div>
</div>
@@ -197,11 +206,11 @@ const formatPermissions = (permissions) => {
</script>
<style scoped>
/* 卡片样式 */
/* 卡片样式 - 使用CSS变量 */
.card {
background: rgba(255, 255, 255, 0.95);
background: var(--surface-color);
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.2);
border: 1px solid var(--border-color);
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
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);
}
/* 统计卡片样式 */
: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 {
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: 1px solid rgba(255, 255, 255, 0.3);
border: 1px solid var(--border-color);
padding: 16px;
position: relative;
overflow: hidden;
@@ -264,6 +279,12 @@ const formatPermissions = (permissions) => {
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 {
opacity: 1;
}

View File

@@ -1,56 +1,56 @@
<template>
<div class="card p-4 md:p-6">
<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">
<i class="fas fa-coins mr-2 text-sm text-yellow-500 md:mr-3 md:text-base" />
Token 使用分布
</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
>
</h3>
<div class="space-y-2 md:space-y-3">
<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" />
输入 Token
</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)
}}</span>
</div>
<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" />
输出 Token
</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)
}}</span>
</div>
<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" />
缓存创建 Token
</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)
}}</span>
</div>
<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" />
缓存读取 Token
</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)
}}</span>
</div>
</div>
<div class="mt-3 border-t border-gray-200 pt-3 md:mt-4 md:pt-4">
<div class="flex items-center justify-between font-bold text-gray-900">
<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 dark:text-gray-100">
<span class="text-sm md:text-base"
>{{ statsPeriod === 'daily' ? '今日' : '本月' }}总计</span
>
@@ -87,11 +87,11 @@ const formatNumber = (num) => {
</script>
<style scoped>
/* 卡片样式 */
/* 卡片样式 - 使用CSS变量 */
.card {
background: rgba(255, 255, 255, 0.95);
background: var(--surface-color);
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.2);
border: 1px solid var(--border-color);
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
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 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>

View File

@@ -2,13 +2,18 @@
<div ref="triggerRef" class="relative">
<!-- 选择器主体 -->
<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 }"
@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
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 }"
/>
</div>
@@ -26,27 +31,27 @@
<div
v-if="showDropdown"
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"
>
<!-- 搜索框 -->
<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">
<input
ref="searchInput"
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="搜索账号名称..."
style="padding-left: 40px; padding-right: 36px"
type="text"
@input="handleSearch"
/>
<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
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"
@click="clearSearch"
>
@@ -59,59 +64,67 @@
<div class="custom-scrollbar flex-1 overflow-y-auto">
<!-- 默认选项 -->
<div
class="cursor-pointer px-4 py-2 transition-colors hover:bg-gray-50"
:class="{ 'bg-blue-50': !modelValue }"
class="cursor-pointer px-4 py-2 transition-colors hover:bg-gray-50 dark:hover:bg-gray-700"
:class="{ 'bg-blue-50 dark:bg-blue-900/20': !modelValue }"
@click="selectAccount(null)"
>
<span class="text-gray-700">{{ defaultOptionText }}</span>
<span class="text-gray-700 dark:text-gray-300">{{ defaultOptionText }}</span>
</div>
<!-- 分组选项 -->
<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
v-for="group in filteredGroups"
:key="`group:${group.id}`"
class="cursor-pointer px-4 py-2 transition-colors hover:bg-gray-50"
:class="{ 'bg-blue-50': modelValue === `group:${group.id}` }"
class="cursor-pointer px-4 py-2 transition-colors hover:bg-gray-50 dark:hover:bg-gray-700"
:class="{ 'bg-blue-50 dark:bg-blue-900/20': modelValue === `group:${group.id}` }"
@click="selectAccount(`group:${group.id}`)"
>
<div class="flex items-center justify-between">
<span class="text-gray-700">{{ group.name }}</span>
<span class="text-xs text-gray-500">{{ group.memberCount || 0 }} 个成员</span>
<span class="text-gray-700 dark:text-gray-300">{{ group.name }}</span>
<span class="text-xs text-gray-500 dark:text-gray-400"
>{{ group.memberCount || 0 }} 个成员</span
>
</div>
</div>
</div>
<!-- OAuth 账号 -->
<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 专属账号' }}
</div>
<div
v-for="account in filteredOAuthAccounts"
:key="account.id"
class="cursor-pointer px-4 py-2 transition-colors hover:bg-gray-50"
:class="{ 'bg-blue-50': modelValue === account.id }"
class="cursor-pointer px-4 py-2 transition-colors hover:bg-gray-50 dark:hover:bg-gray-700"
:class="{ 'bg-blue-50 dark:bg-blue-900/20': modelValue === account.id }"
@click="selectAccount(account.id)"
>
<div class="flex items-center justify-between">
<div>
<span class="text-gray-700">{{ account.name }}</span>
<span class="text-gray-700 dark:text-gray-300">{{ account.name }}</span>
<span
class="ml-2 rounded-full px-2 py-0.5 text-xs"
:class="
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'
? 'bg-orange-100 text-orange-700'
: 'bg-red-100 text-red-700'
? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
"
>
{{ getAccountStatusText(account) }}
</span>
</div>
<span class="text-xs text-gray-400">
<span class="text-xs text-gray-400 dark:text-gray-500">
{{ formatDate(account.createdAt) }}
</span>
</div>
@@ -120,33 +133,37 @@
<!-- Console 账号 Claude -->
<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 专属账号
</div>
<div
v-for="account in filteredConsoleAccounts"
:key="account.id"
class="cursor-pointer px-4 py-2 transition-colors hover:bg-gray-50"
:class="{ 'bg-blue-50': modelValue === `console:${account.id}` }"
class="cursor-pointer px-4 py-2 transition-colors hover:bg-gray-50 dark:hover:bg-gray-700"
:class="{
'bg-blue-50 dark:bg-blue-900/20': modelValue === `console:${account.id}`
}"
@click="selectAccount(`console:${account.id}`)"
>
<div class="flex items-center justify-between">
<div>
<span class="text-gray-700">{{ account.name }}</span>
<span class="text-gray-700 dark:text-gray-300">{{ account.name }}</span>
<span
class="ml-2 rounded-full px-2 py-0.5 text-xs"
:class="
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'
? 'bg-orange-100 text-orange-700'
: 'bg-red-100 text-red-700'
? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
"
>
{{ getAccountStatusText(account) }}
</span>
</div>
<span class="text-xs text-gray-400">
<span class="text-xs text-gray-400 dark:text-gray-500">
{{ formatDate(account.createdAt) }}
</span>
</div>
@@ -154,7 +171,10 @@
</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" />
<p class="text-sm">没有找到匹配的账号</p>
</div>

View File

@@ -14,10 +14,10 @@
<i class="fas fa-exclamation-triangle text-lg text-white" />
</div>
<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 }}
</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 }}
</div>
</div>
@@ -25,7 +25,7 @@
<div class="flex items-center justify-end gap-3">
<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"
@click="handleCancel"
>
@@ -141,6 +141,10 @@ defineExpose({
backdrop-filter: blur(8px);
}
:global(.dark) .modal {
background: rgba(0, 0, 0, 0.7);
}
.modal-content {
background: white;
border-radius: 16px;
@@ -150,6 +154,12 @@ defineExpose({
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 {
@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;
}
:global(.dark) .modal-content::-webkit-scrollbar-track {
background: #374151;
}
.modal-content::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
}
:global(.dark) .modal-content::-webkit-scrollbar-thumb {
background: #4b5563;
}
.modal-content::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
:global(.dark) .modal-content::-webkit-scrollbar-thumb:hover {
background: #6b7280;
}
</style>

View File

@@ -1,7 +1,9 @@
<template>
<Teleport to="body">
<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="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" />
</div>
<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 }}
</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 }}
</p>
</div>
@@ -20,7 +22,7 @@
<div class="flex gap-3">
<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')"
>
{{ cancelText }}
@@ -63,3 +65,15 @@ defineProps({
defineEmits(['confirm', 'cancel'])
</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
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']"
@click="toggleDropdown"
>
<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 }}
</span>
<i
: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'
]"
></i>
@@ -32,7 +34,7 @@
<div
v-if="isOpen"
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"
>
<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="[
option.value === modelValue
? 'bg-blue-50 font-medium text-blue-700'
: 'text-gray-700 hover:bg-gray-50'
? 'bg-blue-50 font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
: 'text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-700'
]"
@click="selectOption(option)"
>

View File

@@ -2,7 +2,7 @@
<div class="flex items-center gap-4">
<!-- Logo区域 -->
<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">
<img
@@ -12,9 +12,9 @@
:src="logoSrc"
@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>
<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>
<!-- 标题区域 -->
@@ -25,11 +25,14 @@
{{ title }}
</h1>
</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" />
</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 }}
</p>
</div>

View File

@@ -2,13 +2,16 @@
<div class="stat-card">
<div class="flex items-start justify-between">
<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 }}
</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 }}
</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 }}
</p>
</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);
}
:global(.dark) .toast {
background: #1f2937;
border: 1px solid #374151;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.toast-show {
transform: translateX(0);
opacity: 1;
@@ -227,6 +233,11 @@ defineExpose({
color: #6b7280;
}
:global(.dark) .toast-close:hover {
background: #374151;
color: #9ca3af;
}
.toast-progress {
position: absolute;
bottom: 0;
@@ -256,14 +267,26 @@ defineExpose({
background: #d1fae5;
}
:global(.dark) .toast-success .toast-icon {
background: #064e3b;
}
.toast-success .toast-title {
color: #065f46;
}
:global(.dark) .toast-success .toast-title {
color: #10b981;
}
.toast-success .toast-message {
color: #047857;
}
:global(.dark) .toast-success .toast-message {
color: #34d399;
}
.toast-success .toast-progress {
background: #10b981;
}
@@ -278,14 +301,26 @@ defineExpose({
background: #fee2e2;
}
:global(.dark) .toast-error .toast-icon {
background: #7f1d1d;
}
.toast-error .toast-title {
color: #991b1b;
}
:global(.dark) .toast-error .toast-title {
color: #ef4444;
}
.toast-error .toast-message {
color: #dc2626;
}
:global(.dark) .toast-error .toast-message {
color: #f87171;
}
.toast-error .toast-progress {
background: #ef4444;
}
@@ -300,14 +335,26 @@ defineExpose({
background: #fef3c7;
}
:global(.dark) .toast-warning .toast-icon {
background: #78350f;
}
.toast-warning .toast-title {
color: #92400e;
}
:global(.dark) .toast-warning .toast-title {
color: #f59e0b;
}
.toast-warning .toast-message {
color: #d97706;
}
:global(.dark) .toast-warning .toast-message {
color: #fbbf24;
}
.toast-warning .toast-progress {
background: #f59e0b;
}
@@ -322,14 +369,26 @@ defineExpose({
background: #dbeafe;
}
:global(.dark) .toast-info .toast-icon {
background: #1e3a8a;
}
.toast-info .toast-title {
color: #1e40af;
}
:global(.dark) .toast-info .toast-title {
color: #3b82f6;
}
.toast-info .toast-message {
color: #2563eb;
}
:global(.dark) .toast-info .toast-message {
color: #60a5fa;
}
.toast-info .toast-progress {
background: #3b82f6;
}

View File

@@ -13,12 +13,12 @@
:logo-src="oemSettings.siteIconData || oemSettings.siteIcon"
subtitle="管理后台"
:title="oemSettings.siteName"
title-class="text-white"
title-class="text-white dark:text-gray-100"
>
<template #after-title>
<!-- 版本信息 -->
<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
>
<!-- 更新提示 -->
@@ -36,95 +36,112 @@
</template>
</LogoTitle>
</div>
<!-- 用户菜单 -->
<div class="user-menu-container relative">
<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"
@click="userMenuOpen = !userMenuOpen"
>
<i class="fas fa-user-circle" />
<span class="hidden sm:inline">{{ currentUser.username || 'Admin' }}</span>
<i
class="fas fa-chevron-down text-xs transition-transform duration-200"
:class="{ 'rotate-180': userMenuOpen }"
/>
</button>
<!-- 主题切换和用户菜单 -->
<div class="flex items-center gap-2 sm:gap-4">
<!-- 主题切换按钮 -->
<div class="flex items-center">
<ThemeToggle mode="dropdown" />
</div>
<!-- 悬浮菜单 -->
<!-- 分隔线 -->
<div
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"
style="z-index: 999999"
@click.stop
>
<!-- 版本信息 -->
<div class="border-b border-gray-100 px-4 py-3">
<div class="flex items-center justify-between text-sm">
<span class="text-gray-500">当前版本</span>
<span class="font-mono text-gray-700">v{{ versionInfo.current || '...' }}</span>
</div>
<div v-if="versionInfo.hasUpdate" class="mt-2">
<div class="mb-2 flex items-center justify-between text-sm">
<span class="font-medium text-green-600">
<i class="fas fa-arrow-up mr-1" />有新版本
</span>
<span class="font-mono text-green-600">v{{ versionInfo.latest }}</span>
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">
<button
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"
>
<i class="fas fa-user-circle text-sm sm:text-base" />
<span class="hidden sm:inline">{{ currentUser.username || 'Admin' }}</span>
<i
class="fas fa-chevron-down ml-1 text-xs transition-transform duration-200"
:class="{ 'rotate-180': userMenuOpen }"
/>
</button>
<!-- 悬浮菜单 -->
<div
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 dark:border-gray-700 dark:bg-gray-800 sm:w-56"
style="z-index: 999999"
@click.stop
>
<!-- 版本信息 -->
<div class="border-b border-gray-100 px-4 py-3 dark:border-gray-700">
<div class="flex items-center justify-between text-sm">
<span class="text-gray-500 dark:text-gray-400">当前版本</span>
<span class="font-mono text-gray-700 dark:text-gray-300"
>v{{ versionInfo.current || '...' }}</span
>
</div>
<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"
:href="versionInfo.releaseInfo?.htmlUrl || '#'"
target="_blank"
>
<i class="fas fa-external-link-alt mr-1" />查看更新
</a>
</div>
<div
v-else-if="versionInfo.checkingUpdate"
class="mt-2 text-center text-xs text-gray-500"
>
<i class="fas fa-spinner fa-spin mr-1" />检查更新中...
</div>
<div v-else class="mt-2 text-center">
<!-- 已是最新版提醒 -->
<transition mode="out-in" name="fade">
<div
v-if="versionInfo.noUpdateMessage"
key="message"
class="inline-block rounded-lg border border-green-200 bg-green-100 px-3 py-1.5"
>
<p class="text-xs font-medium text-green-700">
<i class="fas fa-check-circle mr-1" />当前已是最新版本
</p>
<div v-if="versionInfo.hasUpdate" class="mt-2">
<div class="mb-2 flex items-center justify-between text-sm">
<span class="font-medium text-green-600 dark:text-green-400">
<i class="fas fa-arrow-up mr-1" />有新版本
</span>
<span class="font-mono text-green-600 dark:text-green-400"
>v{{ versionInfo.latest }}</span
>
</div>
<button
v-else
key="button"
class="text-xs text-blue-500 transition-colors hover:text-blue-700"
@click="checkForUpdates()"
<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"
:href="versionInfo.releaseInfo?.htmlUrl || '#'"
target="_blank"
>
<i class="fas fa-sync-alt mr-1" />查更新
</button>
</transition>
<i class="fas fa-external-link-alt mr-1" />更新
</a>
</div>
<div
v-else-if="versionInfo.checkingUpdate"
class="mt-2 text-center text-xs text-gray-500 dark:text-gray-400"
>
<i class="fas fa-spinner fa-spin mr-1" />检查更新中...
</div>
<div v-else class="mt-2 text-center">
<!-- 已是最新版提醒 -->
<transition mode="out-in" name="fade">
<div
v-if="versionInfo.noUpdateMessage"
key="message"
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 dark:text-green-400">
<i class="fas fa-check-circle mr-1" />当前已是最新版本
</p>
</div>
<button
v-else
key="button"
class="text-xs text-blue-500 transition-colors hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
@click="checkForUpdates()"
>
<i class="fas fa-sync-alt mr-1" />检查更新
</button>
</transition>
</div>
</div>
<button
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"
>
<i class="fas fa-key text-blue-500" />
<span>修改账户信息</span>
</button>
<hr class="my-2 border-gray-200 dark:border-gray-700" />
<button
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"
>
<i class="fas fa-sign-out-alt text-red-500" />
<span>退出登录</span>
</button>
</div>
<button
class="flex w-full items-center gap-3 px-4 py-3 text-left text-gray-700 transition-colors hover:bg-gray-50"
@click="openChangePasswordModal"
>
<i class="fas fa-key text-blue-500" />
<span>修改账户信息</span>
</button>
<hr class="my-2 border-gray-200" />
<button
class="flex w-full items-center gap-3 px-4 py-3 text-left text-gray-700 transition-colors hover:bg-gray-50"
@click="logout"
>
<i class="fas fa-sign-out-alt text-red-500" />
<span>退出登录</span>
</button>
</div>
</div>
</div>
@@ -143,10 +160,10 @@
>
<i class="fas fa-key text-white" />
</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>
<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"
>
<i class="fas fa-times text-xl" />
@@ -158,29 +175,37 @@
@submit.prevent="changePassword"
>
<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
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
type="text"
: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>
<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
v-model="changePasswordForm.newUsername"
class="form-input w-full"
placeholder="输入新用户名(留空保持不变)"
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>
<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
v-model="changePasswordForm.currentPassword"
class="form-input w-full"
@@ -191,7 +216,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
>
<input
v-model="changePasswordForm.newPassword"
class="form-input w-full"
@@ -199,11 +226,13 @@
required
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>
<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
v-model="changePasswordForm.confirmPassword"
class="form-input w-full"
@@ -215,7 +244,7 @@
<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"
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="closeChangePasswordModal"
>
@@ -243,6 +272,7 @@ import { useAuthStore } from '@/stores/auth'
import { showToast } from '@/utils/toast'
import { apiClient } from '@/config/api'
import LogoTitle from '@/components/common/LogoTitle.vue'
import ThemeToggle from '@/components/common/ThemeToggle.vue'
const router = useRouter()
const authStore = useAuthStore()
@@ -430,9 +460,44 @@ onUnmounted(() => {
</script>
<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 {
margin-top: 8px;
animation: slideDown 0.3s ease-out;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* fade过渡动画 */

View File

@@ -13,20 +13,14 @@
<!-- 内容区域 -->
<div class="tab-content">
<router-view v-slot="{ Component }">
<transition mode="out-in" name="slide-up">
<keep-alive :include="['DashboardView', 'ApiKeysView']">
<component :is="Component" />
</keep-alive>
</transition>
</router-view>
<router-view />
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import { ref, watch, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AppHeader from './AppHeader.vue'
import TabBar from './TabBar.vue'
@@ -46,6 +40,35 @@ const tabRouteMap = {
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(
() => route.path,
@@ -53,15 +76,46 @@ watch(
const tabKey = Object.keys(tabRouteMap).find((key) => tabRouteMap[key] === newPath)
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]
}
}
},
{ immediate: true }
}
)
// 处理标签切换
const handleTabChange = (tabKey) => {
const handleTabChange = async (tabKey) => {
// 如果已经在目标路由,不需要做任何事
if (tabRouteMap[tabKey] === route.path) {
return
}
// 先更新activeTab状态
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中加载无需重复加载

View File

@@ -1,9 +1,9 @@
<template>
<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
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"
@change="$emit('tab-change', $event.target.value)"
>
@@ -14,13 +14,17 @@
</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
v-for="tab in tabs"
:key="tab.key"
: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',
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)"
>
@@ -48,7 +52,7 @@ const tabs = [
{ key: 'accounts', name: '账户管理', shortName: '账户', icon: 'fas fa-user-circle' },
{ key: 'userManagement', name: '用户管理', shortName: '用户', icon: 'fas fa-users' },
{ 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>

View File

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

View File

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

View File

@@ -9,6 +9,7 @@ export const useAccountsStore = defineStore('accounts', () => {
const bedrockAccounts = ref([])
const geminiAccounts = ref([])
const openaiAccounts = ref([])
const azureOpenaiAccounts = ref([])
const loading = ref(false)
const error = ref(null)
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 () => {
loading.value = true
@@ -121,7 +141,8 @@ export const useAccountsStore = defineStore('accounts', () => {
fetchClaudeConsoleAccounts(),
fetchBedrockAccounts(),
fetchGeminiAccounts(),
fetchOpenAIAccounts()
fetchOpenAIAccounts(),
fetchAzureOpenAIAccounts()
])
} catch (err) {
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账户
const updateClaudeAccount = async (id, data) => {
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) => {
loading.value = true
@@ -345,6 +406,10 @@ export const useAccountsStore = defineStore('accounts', () => {
endpoint = `/admin/bedrock-accounts/${id}/toggle`
} else if (platform === 'gemini') {
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 {
endpoint = `/admin/openai-accounts/${id}/toggle`
}
@@ -359,6 +424,10 @@ export const useAccountsStore = defineStore('accounts', () => {
await fetchBedrockAccounts()
} else if (platform === 'gemini') {
await fetchGeminiAccounts()
} else if (platform === 'openai') {
await fetchOpenAIAccounts()
} else if (platform === 'azure_openai') {
await fetchAzureOpenAIAccounts()
} else {
await fetchOpenAIAccounts()
}
@@ -388,6 +457,10 @@ export const useAccountsStore = defineStore('accounts', () => {
endpoint = `/admin/bedrock-accounts/${id}`
} else if (platform === 'gemini') {
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 {
endpoint = `/admin/openai-accounts/${id}`
}
@@ -402,6 +475,10 @@ export const useAccountsStore = defineStore('accounts', () => {
await fetchBedrockAccounts()
} else if (platform === 'gemini') {
await fetchGeminiAccounts()
} else if (platform === 'openai') {
await fetchOpenAIAccounts()
} else if (platform === 'azure_openai') {
await fetchAzureOpenAIAccounts()
} else {
await fetchOpenAIAccounts()
}
@@ -580,6 +657,7 @@ export const useAccountsStore = defineStore('accounts', () => {
bedrockAccounts.value = []
geminiAccounts.value = []
openaiAccounts.value = []
azureOpenaiAccounts.value = []
loading.value = false
error.value = null
sortBy.value = ''
@@ -593,6 +671,7 @@ export const useAccountsStore = defineStore('accounts', () => {
bedrockAccounts,
geminiAccounts,
openaiAccounts,
azureOpenaiAccounts,
loading,
error,
sortBy,
@@ -604,17 +683,20 @@ export const useAccountsStore = defineStore('accounts', () => {
fetchBedrockAccounts,
fetchGeminiAccounts,
fetchOpenAIAccounts,
fetchAzureOpenAIAccounts,
fetchAllAccounts,
createClaudeAccount,
createClaudeConsoleAccount,
createBedrockAccount,
createGeminiAccount,
createOpenAIAccount,
createAzureOpenAIAccount,
updateClaudeAccount,
updateClaudeConsoleAccount,
updateBedrockAccount,
updateGeminiAccount,
updateOpenAIAccount,
updateAzureOpenAIAccount,
toggleAccount,
deleteAccount,
refreshClaudeToken,

View File

@@ -19,6 +19,8 @@ export const useDashboardStore = defineStore('dashboard', () => {
claude: { 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 },
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 }
},
todayRequests: 0,
@@ -174,6 +176,8 @@ export const useDashboardStore = defineStore('dashboard', () => {
claude: { 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 },
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 }
},
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="mb-4 flex flex-col gap-4 sm:mb-6">
<div>
<h3 class="mb-1 text-lg font-bold text-gray-900 sm:mb-2 sm:text-xl">账户管理</h3>
<p class="text-sm text-gray-600 sm:text-base">管理您的 Claude Gemini 账户及代理配置</p>
<h3 class="mb-1 text-lg font-bold text-gray-900 dark:text-gray-100 sm:mb-2 sm:text-xl">
账户管理
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 sm:text-base">
管理您的 ClaudeGeminiOpenAI Azure OpenAI 账户及代理配置
</p>
</div>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<!-- 筛选器组 -->
@@ -62,7 +66,7 @@
placement="bottom"
>
<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"
@click.ctrl.exact="loadAccounts(true)"
@click.exact="loadAccounts(false)"
@@ -96,26 +100,26 @@
<div v-if="accountsLoading" class="py-12 text-center">
<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 v-else-if="sortedAccounts.length === 0" class="py-12 text-center">
<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" />
</div>
<p class="text-lg text-gray-500">暂无账户</p>
<p class="mt-2 text-sm text-gray-400">点击上方按钮添加您的第一个账户</p>
<p class="text-lg text-gray-500 dark:text-gray-400">暂无账户</p>
<p class="mt-2 text-sm text-gray-400 dark:text-gray-500">点击上方按钮添加您的第一个账户</p>
</div>
<!-- 桌面端表格视图 -->
<div v-else class="table-container hidden md:block">
<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>
<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')"
>
名称
@@ -130,7 +134,7 @@
<i v-else class="fas fa-sort ml-1 text-gray-400" />
</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')"
>
平台/类型
@@ -145,7 +149,7 @@
<i v-else class="fas fa-sort ml-1 text-gray-400" />
</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')"
>
状态
@@ -160,7 +164,7 @@
<i v-else class="fas fa-sort ml-1 text-gray-400" />
</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')"
>
优先级
@@ -175,33 +179,33 @@
<i v-else class="fas fa-sort ml-1 text-gray-400" />
</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
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
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
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
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>
</tr>
</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">
<td class="px-3 py-4">
<div class="flex items-center">
@@ -213,7 +217,7 @@
<div class="min-w-0">
<div class="flex items-center gap-2">
<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"
>
{{ account.name }}
@@ -238,13 +242,16 @@
</span>
<span
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}`"
>
<i class="fas fa-folder mr-1" />{{ account.groupInfo.name }}
</span>
</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 }}
</div>
</div>
@@ -291,6 +298,19 @@
<span class="mx-1 h-4 w-px bg-gray-400" />
<span class="text-xs font-medium text-gray-950">{{ getOpenAIAuthType() }}</span>
</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
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"
@@ -376,12 +396,15 @@
</span>
<span
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"
>
{{ account.errorMessage }}
</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
</span>
</div>
@@ -403,7 +426,7 @@
:style="{ width: 101 - (account.priority || 50) + '%' }"
/>
</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 }}
</span>
</div>
@@ -425,19 +448,19 @@
<div v-if="account.usage && account.usage.daily" class="space-y-1">
<div class="flex items-center gap-2">
<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
>
</div>
<div class="flex items-center gap-2">
<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
>
</div>
<div
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
</div>
@@ -460,11 +483,11 @@
:style="{ width: account.sessionWindow.progress + '%' }"
/>
</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 }}%
</span>
</div>
<div class="text-xs text-gray-600">
<div class="text-xs text-gray-600 dark:text-gray-300">
<div>
{{
formatSessionWindow(
@@ -488,7 +511,7 @@
<span class="text-xs">N/A</span>
</div>
</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) }}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm font-medium">
@@ -571,7 +594,11 @@
? 'bg-gradient-to-br from-purple-500 to-purple-600'
: account.platform === 'bedrock'
? 'bg-gradient-to-br from-orange-500 to-red-600'
: 'bg-gradient-to-br from-blue-500 to-blue-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'
]"
>
<i
@@ -581,7 +608,11 @@
? 'fas fa-brain'
: account.platform === 'bedrock'
? 'fab fa-aws'
: 'fas fa-robot'
: account.platform === 'azure_openai'
? 'fab fa-microsoft'
: account.platform === 'openai'
? 'fas fa-openai'
: 'fas fa-robot'
]"
/>
</div>
@@ -590,9 +621,11 @@
{{ account.name || account.email }}
</h4>
<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-500">{{ account.type }}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ account.type }}</span>
</div>
</div>
</div>
@@ -612,20 +645,20 @@
<!-- 使用统计 -->
<div class="mb-3 grid grid-cols-2 gap-3">
<div>
<p class="text-xs text-gray-500">今日使用</p>
<p class="text-sm font-semibold text-gray-900">
<p class="text-xs text-gray-500 dark:text-gray-400">今日使用</p>
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ formatNumber(account.usage?.daily?.requests || 0) }} 次
</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
</p>
</div>
<div>
<p class="text-xs text-gray-500">总使用量</p>
<p class="text-sm font-semibold text-gray-900">
<p class="text-xs text-gray-500 dark:text-gray-400">总使用量</p>
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ formatNumber(account.usage?.total?.requests || 0) }} 次
</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
</p>
</div>
@@ -640,22 +673,22 @@
account.sessionWindow &&
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">
<span class="font-medium text-gray-600">会话窗口</span>
<span class="font-medium text-gray-700">
<span class="font-medium text-gray-600 dark:text-gray-300">会话窗口</span>
<span class="font-medium text-gray-700 dark:text-gray-200">
{{ account.sessionWindow.progress }}%
</span>
</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
class="h-full bg-gradient-to-r from-blue-500 to-indigo-600 transition-all duration-300"
:style="{ width: account.sessionWindow.progress + '%' }"
/>
</div>
<div class="flex items-center justify-between text-xs">
<span class="text-gray-500">
<span class="text-gray-500 dark:text-gray-400">
{{
formatSessionWindow(
account.sessionWindow.windowStart,
@@ -675,8 +708,8 @@
<!-- 最后使用时间 -->
<div class="flex items-center justify-between text-xs">
<span class="text-gray-500">最后使用</span>
<span class="text-gray-700">
<span class="text-gray-500 dark:text-gray-400">最后使用</span>
<span class="text-gray-700 dark:text-gray-200">
{{ account.lastUsedAt ? formatRelativeTime(account.lastUsedAt) : '从未使用' }}
</span>
</div>
@@ -686,16 +719,16 @@
v-if="account.proxyConfig && account.proxyConfig.type !== 'none'"
class="flex items-center justify-between text-xs"
>
<span class="text-gray-500">代理</span>
<span class="text-gray-700">
<span class="text-gray-500 dark:text-gray-400">代理</span>
<span class="text-gray-700 dark:text-gray-200">
{{ account.proxyConfig.type.toUpperCase() }}
</span>
</div>
<!-- 调度优先级 -->
<div class="flex items-center justify-between text-xs">
<span class="text-gray-500">优先级</span>
<span class="font-medium text-gray-700">
<span class="text-gray-500 dark:text-gray-400">优先级</span>
<span class="font-medium text-gray-700 dark:text-gray-200">
{{ account.priority || 50 }}
</span>
</div>
@@ -808,6 +841,7 @@ const platformOptions = ref([
{ value: 'claude-console', label: 'Claude Console', icon: 'fa-terminal' },
{ value: 'gemini', label: 'Gemini', icon: 'fa-google' },
{ 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' }
])
@@ -900,7 +934,8 @@ const loadAccounts = async (forceReload = false) => {
apiClient.get('/admin/claude-console-accounts', { params }),
apiClient.get('/admin/bedrock-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 {
// 只请求指定平台其他平台设为null占位
@@ -946,7 +981,7 @@ const loadAccounts = async (forceReload = false) => {
// 加载分组成员关系(需要在分组数据加载完成后)
await loadGroupMembers(forceReload)
const [claudeData, claudeConsoleData, bedrockData, geminiData, openaiData] =
const [claudeData, claudeConsoleData, bedrockData, geminiData, openaiData, azureOpenaiData] =
await Promise.all(requests)
const allAccounts = []
@@ -1004,6 +1039,17 @@ const loadAccounts = async (forceReload = false) => {
})
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
} catch (error) {
@@ -1232,6 +1278,8 @@ const deleteAccount = async (account) => {
endpoint = `/admin/bedrock-accounts/${account.id}`
} else if (account.platform === 'openai') {
endpoint = `/admin/openai-accounts/${account.id}`
} else if (account.platform === 'azure_openai') {
endpoint = `/admin/azure-openai-accounts/${account.id}`
} else {
endpoint = `/admin/gemini-accounts/${account.id}`
}
@@ -1304,6 +1352,8 @@ const toggleSchedulable = async (account) => {
endpoint = `/admin/gemini-accounts/${account.id}/toggle-schedulable`
} else if (account.platform === 'openai') {
endpoint = `/admin/openai-accounts/${account.id}/toggle-schedulable`
} else if (account.platform === 'azure_openai') {
endpoint = `/admin/azure-openai-accounts/${account.id}/toggle-schedulable`
} else {
showToast('该账户类型暂不支持调度控制', 'warning')
return

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
<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="flex flex-col items-center justify-between gap-4 md:flex-row">
@@ -9,7 +9,18 @@
:subtitle="currentTab === 'stats' ? 'API Key 使用统计' : '使用教程'"
: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
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"
@@ -18,11 +29,11 @@
<span class="text-xs font-medium md:text-sm">用户登录</span>
</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"
>
<i class="fas fa-cog text-sm" />
<span class="text-xs font-medium md:text-sm">管理后台</span>
<i class="fas fa-shield-alt text-sm md:text-base" />
<span class="text-xs font-semibold tracking-wide md:text-sm">管理后台</span>
</router-link>
</div>
</div>
@@ -60,7 +71,7 @@
<!-- 错误提示 -->
<div v-if="error" class="mb-6 md:mb-8">
<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" />
{{ error }}
@@ -71,13 +82,15 @@
<div v-if="statsData" class="fade-in">
<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
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">
<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 class="flex w-full gap-2 md:w-auto">
<button
@@ -127,11 +140,13 @@
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
import { useRoute } from 'vue-router'
import { storeToRefs } from 'pinia'
import { useApiStatsStore } from '@/stores/apistats'
import { useThemeStore } from '@/stores/theme'
import LogoTitle from '@/components/common/LogoTitle.vue'
import ThemeToggle from '@/components/common/ThemeToggle.vue'
import ApiKeyInput from '@/components/apistats/ApiKeyInput.vue'
import StatsOverview from '@/components/apistats/StatsOverview.vue'
import TokenDistribution from '@/components/apistats/TokenDistribution.vue'
@@ -141,10 +156,14 @@ import TutorialView from './TutorialView.vue'
const route = useRoute()
const apiStatsStore = useApiStatsStore()
const themeStore = useThemeStore()
// 当前标签页
const currentTab = ref('stats')
// 主题相关
const isDarkMode = computed(() => themeStore.isDarkMode)
const {
apiKey,
apiId,
@@ -179,6 +198,9 @@ const handleKeyDown = (event) => {
onMounted(() => {
console.log('API Stats Page loaded')
// 初始化主题(因为该页面不在 MainLayout 内)
themeStore.initTheme()
// 加载 OEM 设置
loadOemSettings()
@@ -224,6 +246,14 @@ watch(apiKey, (newValue) => {
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 {
content: '';
position: fixed;
@@ -239,11 +269,27 @@ watch(apiKey, (newValue) => {
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 {
background: rgba(255, 255, 255, 0.95);
background: var(--glass-strong-color);
backdrop-filter: blur(25px);
border: 1px solid rgba(255, 255, 255, 0.3);
border: 1px solid var(--border-color);
box-shadow:
0 25px 50px -12px rgba(0, 0, 0, 0.25),
0 0 0 1px rgba(255, 255, 255, 0.05),
@@ -252,6 +298,14 @@ watch(apiKey, (newValue) => {
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 {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
@@ -296,38 +350,76 @@ watch(apiKey, (newValue) => {
left: 100%;
}
/* 管理后台按钮 */
.admin-button {
/* 管理后台按钮 - 精致版本 */
.admin-button-refined {
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;
box-shadow:
0 4px 6px -1px rgba(102, 126, 234, 0.3),
0 2px 4px -1px rgba(102, 126, 234, 0.1);
0 4px 12px rgba(102, 126, 234, 0.25),
inset 0 1px 1px rgba(255, 255, 255, 0.2);
position: relative;
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: '';
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;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
opacity: 0;
transition: opacity 0.3s ease;
}
.admin-button:hover {
transform: translateY(-2px);
.admin-button-refined:hover {
transform: translateY(-2px) scale(1.02);
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
box-shadow:
0 10px 15px -3px rgba(102, 126, 234, 0.4),
0 4px 6px -2px rgba(102, 126, 234, 0.15);
0 8px 20px rgba(118, 75, 162, 0.35),
inset 0 1px 1px rgba(255, 255, 255, 0.3);
border-color: rgba(255, 255, 255, 0.4);
color: white;
}
.admin-button:hover::before {
left: 100%;
.admin-button-refined:hover::before {
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) {
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 {
background: rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.8);
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 胶囊按钮样式 */
@@ -380,6 +486,11 @@ watch(apiKey, (newValue) => {
justify-content: center;
}
/* 暗夜模式下的Tab按钮基础样式 */
:global(html.dark) .tab-pill-button {
color: rgba(209, 213, 219, 0.8);
}
@media (min-width: 768px) {
.tab-pill-button {
padding: 0.625rem 1.25rem;
@@ -392,6 +503,11 @@ watch(apiKey, (newValue) => {
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 {
background: white;
color: #764ba2;
@@ -400,6 +516,14 @@ watch(apiKey, (newValue) => {
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 {
font-size: 0.875rem;
}

View File

@@ -7,11 +7,15 @@
<div class="stat-card">
<div class="flex items-center justify-between">
<div>
<p class="mb-1 text-xs font-semibold text-gray-600 sm:text-sm">总API Keys</p>
<p class="text-2xl font-bold text-gray-900 sm:text-3xl">
<p class="mb-1 text-xs font-semibold text-gray-600 dark:text-gray-400 sm:text-sm">
总API Keys
</p>
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100 sm:text-3xl">
{{ dashboardData.totalApiKeys }}
</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 class="stat-icon flex-shrink-0 bg-gradient-to-br from-blue-500 to-blue-600">
<i class="fas fa-key" />
@@ -22,9 +26,11 @@
<div class="stat-card">
<div class="flex items-center justify-between">
<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">
<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 }}
</p>
<!-- 各平台账户数量展示 -->
@@ -39,7 +45,7 @@
:title="`Claude: ${dashboardData.accountsByPlatform.claude.total} 个 (正常: ${dashboardData.accountsByPlatform.claude.normal})`"
>
<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
}}</span>
</div>
@@ -53,7 +59,7 @@
:title="`Console: ${dashboardData.accountsByPlatform['claude-console'].total} 个 (正常: ${dashboardData.accountsByPlatform['claude-console'].normal})`"
>
<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
}}</span>
</div>
@@ -67,7 +73,7 @@
:title="`Gemini: ${dashboardData.accountsByPlatform.gemini.total} 个 (正常: ${dashboardData.accountsByPlatform.gemini.normal})`"
>
<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
}}</span>
</div>
@@ -81,7 +87,7 @@
:title="`Bedrock: ${dashboardData.accountsByPlatform.bedrock.total} 个 (正常: ${dashboardData.accountsByPlatform.bedrock.normal})`"
>
<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
}}</span>
</div>
@@ -95,18 +101,35 @@
:title="`OpenAI: ${dashboardData.accountsByPlatform.openai.total} 个 (正常: ${dashboardData.accountsByPlatform.openai.normal})`"
>
<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
}}</span>
</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>
<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 }}
<span v-if="dashboardData.abnormalAccounts > 0" class="text-red-600">
| 异常: {{ dashboardData.abnormalAccounts }}
</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 }}
</span>
<span v-if="dashboardData.rateLimitedAccounts > 0" class="text-yellow-600">
@@ -123,11 +146,13 @@
<div class="stat-card">
<div class="flex items-center justify-between">
<div>
<p class="mb-1 text-xs font-semibold text-gray-600 sm:text-sm">今日请求</p>
<p class="text-2xl font-bold text-gray-900 sm:text-3xl">
<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-gray-900 dark:text-gray-100 sm:text-3xl">
{{ dashboardData.todayRequests }}
</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) }}
</p>
</div>
@@ -140,11 +165,15 @@
<div class="stat-card">
<div class="flex items-center justify-between">
<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">
{{ dashboardData.systemStatus }}
</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 class="stat-icon flex-shrink-0 bg-gradient-to-br from-yellow-500 to-orange-500">
<i class="fas fa-heartbeat" />
@@ -160,7 +189,9 @@
<div class="stat-card">
<div class="flex items-center justify-between">
<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">
<p class="text-xl font-bold text-blue-600 sm:text-2xl md:text-3xl">
{{
@@ -176,7 +207,7 @@
>/ {{ costsData.todayCosts.formatted.totalCost }}</span
>
</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">
<span
>输入:
@@ -214,7 +245,9 @@
<div class="stat-card">
<div class="flex items-center justify-between">
<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">
<p class="text-xl font-bold text-emerald-600 sm:text-2xl md:text-3xl">
{{
@@ -230,7 +263,7 @@
>/ {{ costsData.totalCosts.formatted.totalCost }}</span
>
</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">
<span
>输入:
@@ -268,14 +301,14 @@
<div class="stat-card">
<div class="flex items-center justify-between">
<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
<span class="text-xs text-gray-400">({{ dashboardData.metricsWindow }}分钟)</span>
</p>
<p class="text-2xl font-bold text-orange-600 sm:text-3xl">
{{ dashboardData.realtimeRPM || 0 }}
</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">
<i class="fas fa-exclamation-circle" /> 历史数据
@@ -291,14 +324,14 @@
<div class="stat-card">
<div class="flex items-center justify-between">
<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
<span class="text-xs text-gray-400">({{ dashboardData.metricsWindow }}分钟)</span>
</p>
<p class="text-2xl font-bold text-rose-600 sm:text-3xl">
{{ formatNumber(dashboardData.realtimeTPM || 0) }}
</p>
<p class="mt-1 text-xs text-gray-500">
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
每分钟Token数
<span v-if="dashboardData.isHistoricalMetrics" class="text-yellow-600">
<i class="fas fa-exclamation-circle" /> 历史数据
@@ -315,18 +348,22 @@
<!-- 模型消费统计 -->
<div class="mb-8">
<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-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
v-for="option in dateFilter.presetOptions"
:key="option.value"
:class="[
'rounded-md px-3 py-1 text-sm font-medium transition-colors',
dateFilter.preset === option.value && dateFilter.type === 'preset'
? 'bg-white text-blue-600 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
? 'bg-white text-blue-600 shadow-sm dark:bg-gray-800'
: 'text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-gray-100'
]"
@click="setDateFilterPreset(option.value)"
>
@@ -335,13 +372,13 @@
</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
:class="[
'rounded-md px-3 py-1 text-sm font-medium transition-colors',
trendGranularity === 'day'
? 'bg-white text-blue-600 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
? 'bg-white text-blue-600 shadow-sm dark:bg-gray-800'
: 'text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-gray-100'
]"
@click="setTrendGranularity('day')"
>
@@ -351,8 +388,8 @@
:class="[
'rounded-md px-3 py-1 text-sm font-medium transition-colors',
trendGranularity === 'hour'
? 'bg-white text-blue-600 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
? 'bg-white text-blue-600 shadow-sm dark:bg-gray-800'
: 'text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-gray-100'
]"
@click="setTrendGranularity('hour')"
>
@@ -385,17 +422,17 @@
<!-- 刷新控制 -->
<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">
<input v-model="autoRefreshEnabled" class="peer sr-only" type="checkbox" />
<!-- 更小的开关 -->
<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
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
v-if="autoRefreshEnabled"
@@ -410,7 +447,7 @@
<!-- 刷新按钮 -->
<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"
title="立即刷新数据"
@click="refreshAllData()"
@@ -425,7 +462,9 @@
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<!-- 饼图 -->
<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">
<canvas ref="modelUsageChart" />
</div>
@@ -433,48 +472,62 @@
<!-- 详细数据表格 -->
<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">
<p class="text-sm text-gray-500 sm:text-base">暂无模型使用数据</p>
</div>
<div v-else class="max-h-[250px] overflow-auto sm:max-h-[300px]">
<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>
<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
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 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
</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
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>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
<tr v-for="stat in dashboardModelStats" :key="stat.model" class="hover:bg-gray-50">
<td class="px-2 py-2 text-xs text-gray-900 sm:px-4 sm:text-sm">
<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 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">
{{ stat.model }}
</span>
</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) }}
</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) }}
</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"
>
<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) }}%
</span>
@@ -512,15 +565,17 @@
<div class="mb-4 sm:mb-6 md:mb-8">
<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">
<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
:class="[
'rounded-md px-2 py-1 text-xs font-medium transition-colors sm:px-3 sm:text-sm',
apiKeysTrendMetric === 'requests'
? 'bg-white text-blue-600 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
? 'bg-white text-blue-600 shadow-sm dark:bg-gray-800'
: 'text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-gray-100'
]"
@click="((apiKeysTrendMetric = 'requests'), updateApiKeysUsageTrendChart())"
>
@@ -531,8 +586,8 @@
:class="[
'rounded-md px-2 py-1 text-xs font-medium transition-colors sm:px-3 sm:text-sm',
apiKeysTrendMetric === 'tokens'
? 'bg-white text-blue-600 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
? 'bg-white text-blue-600 shadow-sm dark:bg-gray-800'
: 'text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-gray-100'
]"
@click="((apiKeysTrendMetric = 'tokens'), updateApiKeysUsageTrendChart())"
>
@@ -541,7 +596,7 @@
</button>
</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">
共 {{ apiKeysTrendData.totalApiKeys }} 个 API Key显示使用量前 10 个
</span>
@@ -556,12 +611,16 @@
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useDashboardStore } from '@/stores/dashboard'
import { useThemeStore } from '@/stores/theme'
import Chart from 'chart.js/auto'
const dashboardStore = useDashboardStore()
const themeStore = useThemeStore()
const { isDarkMode } = storeToRefs(themeStore)
const {
dashboardData,
costsData,
@@ -607,6 +666,13 @@ const isRefreshing = ref(false)
// 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) {
if (num >= 1000000) {
@@ -670,7 +736,8 @@ function createModelUsageChart() {
usePointStyle: true,
font: {
size: 12
}
},
color: chartColors.value.legend
}
},
tooltip: {
@@ -800,10 +867,14 @@ function createUsageTrendChart() {
font: {
size: 16,
weight: 'bold'
}
},
color: chartColors.value.text
},
legend: {
position: 'top'
position: 'top',
labels: {
color: chartColors.value.legend
}
},
tooltip: {
mode: 'index',
@@ -858,7 +929,14 @@ function createUsageTrendChart() {
display: true,
title: {
display: true,
text: trendGranularity.value === 'hour' ? '时间' : '日期'
text: trendGranularity === 'hour' ? '时间' : '日期',
color: chartColors.value.text
},
ticks: {
color: chartColors.value.text
},
grid: {
color: chartColors.value.grid
}
},
y: {
@@ -867,12 +945,17 @@ function createUsageTrendChart() {
position: 'left',
title: {
display: true,
text: 'Token数量'
text: 'Token数量',
color: chartColors.value.text
},
ticks: {
callback: function (value) {
return formatNumber(value)
}
},
color: chartColors.value.text
},
grid: {
color: chartColors.value.grid
}
},
y1: {
@@ -881,7 +964,8 @@ function createUsageTrendChart() {
position: 'right',
title: {
display: true,
text: '请求数'
text: '请求数',
color: chartColors.value.text
},
grid: {
drawOnChartArea: false
@@ -889,7 +973,8 @@ function createUsageTrendChart() {
ticks: {
callback: function (value) {
return value.toLocaleString()
}
},
color: chartColors.value.text
}
},
y2: {
@@ -998,7 +1083,8 @@ function createApiKeysUsageTrendChart() {
usePointStyle: true,
font: {
size: 12
}
},
color: chartColors.value.legend
}
},
tooltip: {
@@ -1062,19 +1148,31 @@ function createApiKeysUsageTrendChart() {
display: true,
title: {
display: true,
text: trendGranularity.value === 'hour' ? '时间' : '日期'
text: trendGranularity === 'hour' ? '时间' : '日期',
color: chartColors.value.text
},
ticks: {
color: chartColors.value.text
},
grid: {
color: chartColors.value.grid
}
},
y: {
beginAtZero: true,
title: {
display: true,
text: apiKeysTrendMetric.value === 'tokens' ? 'Token 数量' : '请求次数'
text: apiKeysTrendMetric.value === 'tokens' ? 'Token 数量' : '请求次数',
color: chartColors.value.text
},
ticks: {
callback: function (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 () => {
// 加载所有数据
@@ -1208,19 +1315,8 @@ onUnmounted(() => {
</script>
<style scoped>
/* 自定义日期选择器样式 */
.custom-date-picker :deep(.el-input__inner) {
@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) {
/* 日期选择器基本样式调整 - 让Element Plus官方暗黑模式生效 */
.custom-date-picker {
font-size: 13px;
}

View File

@@ -1,5 +1,10 @@
<template>
<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
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"
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>
<form class="space-y-4 sm:space-y-6" @submit.prevent="handleLogin">
<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
v-model="loginForm.username"
class="form-input w-full"
@@ -45,7 +52,9 @@
</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
v-model="loginForm.password"
class="form-input w-full"
@@ -68,7 +77,7 @@
<div
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 }}
</div>
@@ -79,8 +88,11 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { useThemeStore } from '@/stores/theme'
import ThemeToggle from '@/components/common/ThemeToggle.vue'
const authStore = useAuthStore()
const themeStore = useThemeStore()
const oemLoading = computed(() => authStore.oemLoading)
const loginForm = ref({
@@ -89,6 +101,8 @@ const loginForm = ref({
})
onMounted(() => {
// 初始化主题
themeStore.initTheme()
// 加载OEM设置
authStore.loadOemSettings()
})

File diff suppressed because it is too large Load Diff

View File

@@ -1,26 +1,28 @@
<template>
<div class="card p-3 sm:p-6">
<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" />
Claude Code 使用教程
</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
</p>
</div>
<!-- 系统选择标签 -->
<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
v-for="system in tutorialSystems"
:key="system.key"
: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',
activeTutorialSystem === system.key
? 'bg-white text-blue-600 shadow-sm'
: 'text-gray-600 hover:bg-white/50 hover:text-gray-900'
? 'bg-white text-blue-600 shadow-sm dark:bg-gray-800'
: '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"
>
@@ -34,14 +36,16 @@
<div v-if="activeTutorialSystem === 'windows'" class="tutorial-content">
<!-- 第一步安装 Node.js -->
<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
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
>
安装 Node.js 环境
</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 环境才能运行
</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"
>
<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" />
Windows 安装方法
</h5>
<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
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>
打开浏览器访问
<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
>
</li>
<li>点击 "LTS" 版本进行下载推荐长期支持版本</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>
</ol>
</div>
<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-xs text-gray-600 sm:text-sm">
<p class="mb-2 text-sm text-gray-700 dark:text-gray-600 sm:mb-3 sm:text-base">
方法二使用包管理器
</p>
<p class="mb-2 text-xs text-gray-600 dark:text-gray-400 sm:text-sm">
如果你安装了 Chocolatey Scoop可以使用命令行安装
</p>
<div
@@ -116,7 +128,9 @@
<!-- 第二步安装 Claude Code -->
<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
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
@@ -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"
>
<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" />
安装 Claude Code
</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运行以下命令
</p>
<div
@@ -144,7 +158,7 @@
npm install -g @anthropic-ai/claude-code
</div>
</div>
<p class="text-sm text-gray-600">
<p class="text-sm text-gray-600 dark:text-gray-400">
这个命令会从 npm 官方仓库下载并安装最新版本的 Claude Code
</p>
@@ -159,7 +173,7 @@
<!-- 验证安装 -->
<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>
<div
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">
<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
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
@@ -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"
>
<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" />
配置 Claude Code 环境变量
</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 连接到你的中转服务需要设置两个环境变量
</p>
<div class="space-y-4">
<div class="rounded-lg border border-purple-200 bg-white p-3 sm:p-4">
<h6 class="mb-2 text-sm font-medium text-gray-800 sm:text-base">
<div
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 临时设置当前会话
</h6>
<p class="mb-3 text-sm text-gray-600"> PowerShell 中运行以下命令</p>
@@ -216,8 +234,10 @@
</p>
</div>
<div class="rounded-lg border border-purple-200 bg-white p-3 sm:p-4">
<h6 class="mb-2 text-sm font-medium text-gray-800 sm:text-base">
<div
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 永久设置用户级
</h6>
<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">
<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>
<div class="space-y-4">
<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 中验证
</h6>
<div
@@ -279,7 +299,9 @@
</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
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">
<strong>预期输出示例</strong>
</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>cr_xxxxxxxxxxxxxxxxxx</div>
</div>
@@ -306,18 +328,18 @@
<!-- Gemini CLI 环境变量设置 -->
<div class="mt-8">
<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" />
配置 Gemini CLI 环境变量
</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需要设置以下环境变量
</p>
<div class="space-y-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 设置方法
</h6>
<p class="mb-3 text-sm text-gray-600"> PowerShell 中运行以下命令</p>
@@ -340,7 +362,7 @@
</div>
<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 永久设置用户级
</h6>
<p class="mb-3 text-sm text-gray-600"> PowerShell 中运行以下命令</p>
@@ -368,7 +390,9 @@
</div>
<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>
<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"
@@ -386,18 +410,18 @@
<!-- Codex 环境变量设置 -->
<div class="mt-8">
<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" />
配置 Codex 环境变量
</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需要设置以下环境变量
</p>
<div class="space-y-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 设置方法
</h6>
<p class="mb-3 text-sm text-gray-600"> PowerShell 中运行以下命令</p>
@@ -417,7 +441,7 @@
</div>
<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 永久设置用户级
</h6>
<p class="mb-3 text-sm text-gray-600"> PowerShell 中运行以下命令</p>
@@ -456,7 +480,9 @@
<!-- 第四步开始使用 -->
<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
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
@@ -466,13 +492,15 @@
<div
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
</p>
<div class="space-y-4">
<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
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>
<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
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 故障排除 -->
<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" />
Windows 常见问题解决
</h4>
@@ -567,7 +599,9 @@
<div v-else-if="activeTutorialSystem === 'macos'" class="tutorial-content">
<!-- 第一步安装 Node.js -->
<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
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
@@ -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"
>
<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" />
macOS 安装方法
</h5>
<div class="mb-4">
<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 会更方便
</p>
<div
@@ -602,18 +636,22 @@
<div class="mb-4">
<p class="mb-3 text-gray-700">方法二官网下载</p>
<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>
访问
<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
>
</li>
<li>下载适合 macOS LTS 版本</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>
@@ -634,7 +672,7 @@
<!-- 验证安装 -->
<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>
<div
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 -->
<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
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
@@ -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"
>
<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" />
安装 Claude Code
</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运行以下命令
</p>
<div
@@ -688,7 +728,7 @@
<!-- 验证安装 -->
<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>
<div
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">
<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
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
@@ -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"
>
<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" />
配置 Claude Code 环境变量
</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 连接到你的中转服务需要设置两个环境变量
</p>
<div class="space-y-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>
<p class="mb-3 text-sm text-gray-600"> Terminal 中运行以下命令</p>
@@ -746,7 +788,9 @@
</div>
<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 配置文件根据你使用的 shell
</p>
@@ -781,18 +825,20 @@
<!-- Gemini CLI 环境变量设置 -->
<div class="mt-8">
<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" />
配置 Gemini CLI 环境变量
</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需要设置以下环境变量
</p>
<div class="space-y-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>
<div
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 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>
<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"
@@ -848,7 +896,9 @@
</div>
<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>
<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"
@@ -864,18 +914,20 @@
<!-- Codex 环境变量设置 -->
<div class="mt-8">
<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" />
配置 Codex 环境变量
</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需要设置以下环境变量
</p>
<div class="space-y-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>
<div
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 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>
<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"
@@ -937,7 +991,9 @@
<!-- 第四步开始使用 -->
<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
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
@@ -947,13 +1003,15 @@
<div
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
</p>
<div class="space-y-4">
<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
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>
<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
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 故障排除 -->
<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" />
macOS 常见问题解决
</h4>
@@ -1054,7 +1116,9 @@
<div v-else-if="activeTutorialSystem === 'linux'" class="tutorial-content">
<!-- 第一步安装 Node.js -->
<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
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
@@ -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"
>
<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" />
Linux 安装方法
@@ -1087,7 +1151,7 @@
</div>
<div class="mb-4">
<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>
<div
@@ -1112,7 +1176,7 @@
<!-- 验证安装 -->
<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>
<div
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 -->
<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
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
@@ -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"
>
<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" />
安装 Claude Code
</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
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">
<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>
<div
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">
<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
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
@@ -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"
>
<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" />
配置 Claude Code 环境变量
</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 连接到你的中转服务需要设置两个环境变量
</p>
<div class="space-y-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>
<p class="mb-3 text-sm text-gray-600">在终端中运行以下命令</p>
@@ -1222,7 +1292,9 @@
</div>
<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>
<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"
@@ -1255,18 +1327,20 @@
<!-- Gemini CLI 环境变量设置 -->
<div class="mt-8">
<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" />
配置 Gemini CLI 环境变量
</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需要设置以下环境变量
</p>
<div class="space-y-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>
<div
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 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>
<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"
@@ -1322,7 +1398,9 @@
</div>
<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>
<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"
@@ -1338,18 +1416,20 @@
<!-- Codex 环境变量设置 -->
<div class="mt-8">
<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" />
配置 Codex 环境变量
</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需要设置以下环境变量
</p>
<div class="space-y-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>
<div
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 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>
<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"
@@ -1411,7 +1493,9 @@
<!-- 第四步开始使用 -->
<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
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
@@ -1421,13 +1505,15 @@
<div
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
</p>
<div class="space-y-4">
<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
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>
<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
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 故障排除 -->
<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" />
Linux 常见问题解决
</h4>

View File

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