mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 19:09:15 +00:00
Merge branch 'dev'
This commit is contained in:
12
README.md
12
README.md
@@ -17,14 +17,14 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 💎 Claude 拼车 - Claude Code 合租服务推荐
|
## 💎 Claude/Codex 拼车服务推荐
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
| 平台 | 类型 | 介绍 |
|
| 平台 | 类型 | 服务 | 介绍 |
|
||||||
|:---:|:---:|:---|
|
|:---|:---|:---|:---|
|
||||||
| **[pincc.ai](https://pincc.ai/)** | 🏆 **官方运营** | 项目官方直营的Claude拼车服务<br>提供200刀 Claude Code Max 套餐共享服务 |
|
| **[pincc.ai](https://pincc.ai/)** | 🏆 **官方运营** | <small>✅ Claude Code<br>✅ Codex CLI</small> | 项目直营,提供稳定的 Claude Code / Codex CLI 拼车服务 |
|
||||||
| **[ctok.ai](https://ctok.ai/)** | 🤝 合作伙伴 | 社区认可的Claude拼车服务 |
|
| **[ctok.ai](https://ctok.ai/)** | 🤝 合作伙伴 | <small>✅ Claude Code<br>✅ Codex CLI</small> | 社区认证,提供 Claude Code / Codex CLI 拼车 |
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -416,7 +416,7 @@ gemini # 或其他 Gemini CLI 命令
|
|||||||
|
|
||||||
**Codex 配置:**
|
**Codex 配置:**
|
||||||
|
|
||||||
在 `~/.codex/config.toml` 文件中添加以下配置:
|
在 `~/.codex/config.toml` 文件**开头**添加以下配置:
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
model_provider = "crs"
|
model_provider = "crs"
|
||||||
|
|||||||
@@ -84,16 +84,214 @@ function sanitizeData(data, type) {
|
|||||||
return sanitized
|
return sanitized
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CSV 字段映射配置
|
||||||
|
const CSV_FIELD_MAPPING = {
|
||||||
|
// 基本信息
|
||||||
|
id: 'ID',
|
||||||
|
name: '名称',
|
||||||
|
description: '描述',
|
||||||
|
isActive: '状态',
|
||||||
|
createdAt: '创建时间',
|
||||||
|
lastUsedAt: '最后使用时间',
|
||||||
|
createdBy: '创建者',
|
||||||
|
|
||||||
|
// API Key 信息
|
||||||
|
apiKey: 'API密钥',
|
||||||
|
tokenLimit: '令牌限制',
|
||||||
|
|
||||||
|
// 过期设置
|
||||||
|
expirationMode: '过期模式',
|
||||||
|
expiresAt: '过期时间',
|
||||||
|
activationDays: '激活天数',
|
||||||
|
activationUnit: '激活单位',
|
||||||
|
isActivated: '已激活',
|
||||||
|
activatedAt: '激活时间',
|
||||||
|
|
||||||
|
// 权限设置
|
||||||
|
permissions: '服务权限',
|
||||||
|
|
||||||
|
// 限制设置
|
||||||
|
rateLimitWindow: '速率窗口(分钟)',
|
||||||
|
rateLimitRequests: '请求次数限制',
|
||||||
|
rateLimitCost: '费用限制(美元)',
|
||||||
|
concurrencyLimit: '并发限制',
|
||||||
|
dailyCostLimit: '日费用限制(美元)',
|
||||||
|
totalCostLimit: '总费用限制(美元)',
|
||||||
|
weeklyOpusCostLimit: '周Opus费用限制(美元)',
|
||||||
|
|
||||||
|
// 账户绑定
|
||||||
|
claudeAccountId: 'Claude专属账户',
|
||||||
|
claudeConsoleAccountId: 'Claude控制台账户',
|
||||||
|
geminiAccountId: 'Gemini专属账户',
|
||||||
|
openaiAccountId: 'OpenAI专属账户',
|
||||||
|
azureOpenaiAccountId: 'Azure OpenAI专属账户',
|
||||||
|
bedrockAccountId: 'Bedrock专属账户',
|
||||||
|
|
||||||
|
// 限制配置
|
||||||
|
enableModelRestriction: '启用模型限制',
|
||||||
|
restrictedModels: '限制的模型',
|
||||||
|
enableClientRestriction: '启用客户端限制',
|
||||||
|
allowedClients: '允许的客户端',
|
||||||
|
|
||||||
|
// 标签和用户
|
||||||
|
tags: '标签',
|
||||||
|
userId: '用户ID',
|
||||||
|
userUsername: '用户名',
|
||||||
|
|
||||||
|
// 其他信息
|
||||||
|
icon: '图标'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 数据格式化函数
|
||||||
|
function formatCSVValue(key, value, shouldSanitize = false) {
|
||||||
|
if (!value || value === '' || value === 'null' || value === 'undefined') {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (key) {
|
||||||
|
case 'apiKey':
|
||||||
|
if (shouldSanitize && value.length > 10) {
|
||||||
|
return `${value.substring(0, 10)}...[已脱敏]`
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
|
||||||
|
case 'isActive':
|
||||||
|
case 'isActivated':
|
||||||
|
case 'enableModelRestriction':
|
||||||
|
case 'enableClientRestriction':
|
||||||
|
return value === 'true' ? '是' : '否'
|
||||||
|
|
||||||
|
case 'expirationMode':
|
||||||
|
return value === 'activation' ? '首次使用后激活' : value === 'fixed' ? '固定时间' : value
|
||||||
|
|
||||||
|
case 'activationUnit':
|
||||||
|
return value === 'hours' ? '小时' : value === 'days' ? '天' : value
|
||||||
|
|
||||||
|
case 'permissions':
|
||||||
|
switch (value) {
|
||||||
|
case 'all':
|
||||||
|
return '全部服务'
|
||||||
|
case 'claude':
|
||||||
|
return '仅Claude'
|
||||||
|
case 'gemini':
|
||||||
|
return '仅Gemini'
|
||||||
|
case 'openai':
|
||||||
|
return '仅OpenAI'
|
||||||
|
default:
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'restrictedModels':
|
||||||
|
case 'allowedClients':
|
||||||
|
case 'tags':
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value)
|
||||||
|
return Array.isArray(parsed) ? parsed.join('; ') : value
|
||||||
|
} catch {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'createdAt':
|
||||||
|
case 'lastUsedAt':
|
||||||
|
case 'activatedAt':
|
||||||
|
case 'expiresAt':
|
||||||
|
if (value) {
|
||||||
|
try {
|
||||||
|
return new Date(value).toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit'
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
|
||||||
|
case 'rateLimitWindow':
|
||||||
|
case 'rateLimitRequests':
|
||||||
|
case 'concurrencyLimit':
|
||||||
|
case 'activationDays':
|
||||||
|
case 'tokenLimit':
|
||||||
|
return value === '0' || value === 0 ? '无限制' : value
|
||||||
|
|
||||||
|
case 'rateLimitCost':
|
||||||
|
case 'dailyCostLimit':
|
||||||
|
case 'totalCostLimit':
|
||||||
|
case 'weeklyOpusCostLimit':
|
||||||
|
return value === '0' || value === 0 ? '无限制' : `$${value}`
|
||||||
|
|
||||||
|
default:
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转义 CSV 字段
|
||||||
|
function escapeCSVField(field) {
|
||||||
|
if (field === null || field === undefined) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const str = String(field)
|
||||||
|
|
||||||
|
// 如果包含逗号、引号或换行符,需要用引号包围
|
||||||
|
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
|
||||||
|
// 先转义引号(双引号变成两个双引号)
|
||||||
|
const escaped = str.replace(/"/g, '""')
|
||||||
|
return `"${escaped}"`
|
||||||
|
}
|
||||||
|
|
||||||
|
return str
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换数据为 CSV 格式
|
||||||
|
function convertToCSV(exportDataObj, shouldSanitize = false) {
|
||||||
|
if (!exportDataObj.data.apiKeys || exportDataObj.data.apiKeys.length === 0) {
|
||||||
|
throw new Error('CSV format only supports API Keys export. Please use --types=apikeys')
|
||||||
|
}
|
||||||
|
|
||||||
|
const { apiKeys } = exportDataObj.data
|
||||||
|
const fields = Object.keys(CSV_FIELD_MAPPING)
|
||||||
|
const headers = Object.values(CSV_FIELD_MAPPING)
|
||||||
|
|
||||||
|
// 生成标题行
|
||||||
|
const csvLines = [headers.map(escapeCSVField).join(',')]
|
||||||
|
|
||||||
|
// 生成数据行
|
||||||
|
for (const apiKey of apiKeys) {
|
||||||
|
const row = fields.map((field) => {
|
||||||
|
const value = formatCSVValue(field, apiKey[field], shouldSanitize)
|
||||||
|
return escapeCSVField(value)
|
||||||
|
})
|
||||||
|
csvLines.push(row.join(','))
|
||||||
|
}
|
||||||
|
|
||||||
|
return csvLines.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
// 导出数据
|
// 导出数据
|
||||||
async function exportData() {
|
async function exportData() {
|
||||||
try {
|
try {
|
||||||
const outputFile = params.output || `backup-${new Date().toISOString().split('T')[0]}.json`
|
const format = params.format || 'json'
|
||||||
|
const fileExtension = format === 'csv' ? '.csv' : '.json'
|
||||||
|
const defaultFileName = `backup-${new Date().toISOString().split('T')[0]}${fileExtension}`
|
||||||
|
const outputFile = params.output || defaultFileName
|
||||||
const types = params.types ? params.types.split(',') : ['all']
|
const types = params.types ? params.types.split(',') : ['all']
|
||||||
const shouldSanitize = params.sanitize === true
|
const shouldSanitize = params.sanitize === true
|
||||||
|
|
||||||
|
// CSV 格式验证
|
||||||
|
if (format === 'csv' && !types.includes('apikeys') && !types.includes('all')) {
|
||||||
|
logger.error('❌ CSV format only supports API Keys export. Please use --types=apikeys')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
logger.info('🔄 Starting data export...')
|
logger.info('🔄 Starting data export...')
|
||||||
logger.info(`📁 Output file: ${outputFile}`)
|
logger.info(`📁 Output file: ${outputFile}`)
|
||||||
logger.info(`📋 Data types: ${types.join(', ')}`)
|
logger.info(`📋 Data types: ${types.join(', ')}`)
|
||||||
|
logger.info(`📄 Output format: ${format.toUpperCase()}`)
|
||||||
logger.info(`🔒 Sanitize sensitive data: ${shouldSanitize ? 'YES' : 'NO'}`)
|
logger.info(`🔒 Sanitize sensitive data: ${shouldSanitize ? 'YES' : 'NO'}`)
|
||||||
|
|
||||||
// 连接 Redis
|
// 连接 Redis
|
||||||
@@ -203,8 +401,16 @@ async function exportData() {
|
|||||||
logger.success(`✅ Exported ${admins.length} admins`)
|
logger.success(`✅ Exported ${admins.length} admins`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 写入文件
|
// 根据格式写入文件
|
||||||
await fs.writeFile(outputFile, JSON.stringify(exportData, null, 2))
|
let fileContent
|
||||||
|
if (format === 'csv') {
|
||||||
|
fileContent = convertToCSV(exportDataObj, shouldSanitize)
|
||||||
|
// 添加 UTF-8 BOM 以便 Excel 正确识别中文
|
||||||
|
fileContent = `\ufeff${fileContent}`
|
||||||
|
await fs.writeFile(outputFile, fileContent, 'utf8')
|
||||||
|
} else {
|
||||||
|
await fs.writeFile(outputFile, JSON.stringify(exportDataObj, null, 2))
|
||||||
|
}
|
||||||
|
|
||||||
// 显示导出摘要
|
// 显示导出摘要
|
||||||
console.log(`\n${'='.repeat(60)}`)
|
console.log(`\n${'='.repeat(60)}`)
|
||||||
@@ -471,8 +677,9 @@ Commands:
|
|||||||
import Import data from a JSON file to Redis
|
import Import data from a JSON file to Redis
|
||||||
|
|
||||||
Export Options:
|
Export Options:
|
||||||
--output=FILE Output filename (default: backup-YYYY-MM-DD.json)
|
--output=FILE Output filename (default: backup-YYYY-MM-DD.json/.csv)
|
||||||
--types=TYPE,... Data types to export: apikeys,accounts,admins,all (default: all)
|
--types=TYPE,... Data types to export: apikeys,accounts,admins,all (default: all)
|
||||||
|
--format=FORMAT Output format: json,csv (default: json)
|
||||||
--sanitize Remove sensitive data from export
|
--sanitize Remove sensitive data from export
|
||||||
|
|
||||||
Import Options:
|
Import Options:
|
||||||
@@ -492,6 +699,12 @@ Examples:
|
|||||||
|
|
||||||
# Export specific data types
|
# Export specific data types
|
||||||
node scripts/data-transfer.js export --types=apikeys,accounts --output=prod-data.json
|
node scripts/data-transfer.js export --types=apikeys,accounts --output=prod-data.json
|
||||||
|
|
||||||
|
# Export API keys to CSV format
|
||||||
|
node scripts/data-transfer.js export --types=apikeys --format=csv --sanitize
|
||||||
|
|
||||||
|
# Export to CSV with custom filename
|
||||||
|
node scripts/data-transfer.js export --types=apikeys --format=csv --output=api-keys.csv
|
||||||
`)
|
`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -288,12 +288,12 @@ check_redis() {
|
|||||||
# 测试Redis连接
|
# 测试Redis连接
|
||||||
print_info "测试 Redis 连接..."
|
print_info "测试 Redis 连接..."
|
||||||
if command_exists redis-cli; then
|
if command_exists redis-cli; then
|
||||||
local redis_test_cmd="redis-cli -h $REDIS_HOST -p $REDIS_PORT"
|
local redis_args=(-h "$REDIS_HOST" -p "$REDIS_PORT")
|
||||||
if [ -n "$REDIS_PASSWORD" ]; then
|
if [ -n "$REDIS_PASSWORD" ]; then
|
||||||
redis_test_cmd="$redis_test_cmd -a '$REDIS_PASSWORD'"
|
redis_args+=(-a "$REDIS_PASSWORD")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if $redis_test_cmd ping 2>/dev/null | grep -q "PONG"; then
|
if redis-cli "${redis_args[@]}" ping 2>/dev/null | grep -q "PONG"; then
|
||||||
print_success "Redis 连接成功"
|
print_success "Redis 连接成功"
|
||||||
return 0
|
return 0
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
const { v4: uuidv4 } = require('uuid')
|
||||||
|
const config = require('../../config/config')
|
||||||
const apiKeyService = require('../services/apiKeyService')
|
const apiKeyService = require('../services/apiKeyService')
|
||||||
const userService = require('../services/userService')
|
const userService = require('../services/userService')
|
||||||
const logger = require('../utils/logger')
|
const logger = require('../utils/logger')
|
||||||
@@ -80,14 +82,33 @@ const authenticateApiKey = async (req, res, next) => {
|
|||||||
// 检查并发限制
|
// 检查并发限制
|
||||||
const concurrencyLimit = validation.keyData.concurrencyLimit || 0
|
const concurrencyLimit = validation.keyData.concurrencyLimit || 0
|
||||||
if (concurrencyLimit > 0) {
|
if (concurrencyLimit > 0) {
|
||||||
const currentConcurrency = await redis.incrConcurrency(validation.keyData.id)
|
const concurrencyConfig = config.concurrency || {}
|
||||||
|
const leaseSeconds = Math.max(concurrencyConfig.leaseSeconds || 900, 30)
|
||||||
|
const rawRenewInterval =
|
||||||
|
typeof concurrencyConfig.renewIntervalSeconds === 'number'
|
||||||
|
? concurrencyConfig.renewIntervalSeconds
|
||||||
|
: 60
|
||||||
|
let renewIntervalSeconds = rawRenewInterval
|
||||||
|
if (renewIntervalSeconds > 0) {
|
||||||
|
const maxSafeRenew = Math.max(leaseSeconds - 5, 15)
|
||||||
|
renewIntervalSeconds = Math.min(Math.max(renewIntervalSeconds, 15), maxSafeRenew)
|
||||||
|
} else {
|
||||||
|
renewIntervalSeconds = 0
|
||||||
|
}
|
||||||
|
const requestId = uuidv4()
|
||||||
|
|
||||||
|
const currentConcurrency = await redis.incrConcurrency(
|
||||||
|
validation.keyData.id,
|
||||||
|
requestId,
|
||||||
|
leaseSeconds
|
||||||
|
)
|
||||||
logger.api(
|
logger.api(
|
||||||
`📈 Incremented concurrency for key: ${validation.keyData.id} (${validation.keyData.name}), current: ${currentConcurrency}, limit: ${concurrencyLimit}`
|
`📈 Incremented concurrency for key: ${validation.keyData.id} (${validation.keyData.name}), current: ${currentConcurrency}, limit: ${concurrencyLimit}`
|
||||||
)
|
)
|
||||||
|
|
||||||
if (currentConcurrency > concurrencyLimit) {
|
if (currentConcurrency > concurrencyLimit) {
|
||||||
// 如果超过限制,立即减少计数
|
// 如果超过限制,立即减少计数
|
||||||
await redis.decrConcurrency(validation.keyData.id)
|
await redis.decrConcurrency(validation.keyData.id, requestId)
|
||||||
logger.security(
|
logger.security(
|
||||||
`🚦 Concurrency limit exceeded for key: ${validation.keyData.id} (${
|
`🚦 Concurrency limit exceeded for key: ${validation.keyData.id} (${
|
||||||
validation.keyData.name
|
validation.keyData.name
|
||||||
@@ -101,14 +122,39 @@ const authenticateApiKey = async (req, res, next) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const renewIntervalMs =
|
||||||
|
renewIntervalSeconds > 0 ? Math.max(renewIntervalSeconds * 1000, 15000) : 0
|
||||||
|
|
||||||
// 使用标志位确保只减少一次
|
// 使用标志位确保只减少一次
|
||||||
let concurrencyDecremented = false
|
let concurrencyDecremented = false
|
||||||
|
let leaseRenewInterval = null
|
||||||
|
|
||||||
|
if (renewIntervalMs > 0) {
|
||||||
|
leaseRenewInterval = setInterval(() => {
|
||||||
|
redis
|
||||||
|
.refreshConcurrencyLease(validation.keyData.id, requestId, leaseSeconds)
|
||||||
|
.catch((error) => {
|
||||||
|
logger.error(
|
||||||
|
`Failed to refresh concurrency lease for key ${validation.keyData.id}:`,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}, renewIntervalMs)
|
||||||
|
|
||||||
|
if (typeof leaseRenewInterval.unref === 'function') {
|
||||||
|
leaseRenewInterval.unref()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const decrementConcurrency = async () => {
|
const decrementConcurrency = async () => {
|
||||||
if (!concurrencyDecremented) {
|
if (!concurrencyDecremented) {
|
||||||
concurrencyDecremented = true
|
concurrencyDecremented = true
|
||||||
|
if (leaseRenewInterval) {
|
||||||
|
clearInterval(leaseRenewInterval)
|
||||||
|
leaseRenewInterval = null
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const newCount = await redis.decrConcurrency(validation.keyData.id)
|
const newCount = await redis.decrConcurrency(validation.keyData.id, requestId)
|
||||||
logger.api(
|
logger.api(
|
||||||
`📉 Decremented concurrency for key: ${validation.keyData.id} (${validation.keyData.name}), new count: ${newCount}`
|
`📉 Decremented concurrency for key: ${validation.keyData.id} (${validation.keyData.name}), new count: ${newCount}`
|
||||||
)
|
)
|
||||||
@@ -147,6 +193,7 @@ const authenticateApiKey = async (req, res, next) => {
|
|||||||
req.concurrencyInfo = {
|
req.concurrencyInfo = {
|
||||||
apiKeyId: validation.keyData.id,
|
apiKeyId: validation.keyData.id,
|
||||||
apiKeyName: validation.keyData.name,
|
apiKeyName: validation.keyData.name,
|
||||||
|
requestId,
|
||||||
decrementConcurrency
|
decrementConcurrency
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1538,18 +1538,55 @@ class RedisClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 增加并发计数
|
// 获取并发配置
|
||||||
async incrConcurrency(apiKeyId) {
|
_getConcurrencyConfig() {
|
||||||
|
const defaults = {
|
||||||
|
leaseSeconds: 900,
|
||||||
|
cleanupGraceSeconds: 30
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...defaults,
|
||||||
|
...(config.concurrency || {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 增加并发计数(基于租约的有序集合)
|
||||||
|
async incrConcurrency(apiKeyId, requestId, leaseSeconds = null) {
|
||||||
|
if (!requestId) {
|
||||||
|
throw new Error('Request ID is required for concurrency tracking')
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const { leaseSeconds: defaultLeaseSeconds, cleanupGraceSeconds } =
|
||||||
|
this._getConcurrencyConfig()
|
||||||
|
const lease = leaseSeconds || defaultLeaseSeconds
|
||||||
const key = `concurrency:${apiKeyId}`
|
const key = `concurrency:${apiKeyId}`
|
||||||
const count = await this.client.incr(key)
|
const now = Date.now()
|
||||||
|
const expireAt = now + lease * 1000
|
||||||
|
const ttl = Math.max((lease + cleanupGraceSeconds) * 1000, 60000)
|
||||||
|
|
||||||
// 设置过期时间为180秒(3分钟),防止计数器永远不清零
|
const luaScript = `
|
||||||
// 正常情况下请求会在完成时主动减少计数,这只是一个安全保障
|
local key = KEYS[1]
|
||||||
// 180秒足够支持较长的流式请求
|
local member = ARGV[1]
|
||||||
await this.client.expire(key, 180)
|
local expireAt = tonumber(ARGV[2])
|
||||||
|
local now = tonumber(ARGV[3])
|
||||||
|
local ttl = tonumber(ARGV[4])
|
||||||
|
|
||||||
logger.database(`🔢 Incremented concurrency for key ${apiKeyId}: ${count}`)
|
redis.call('ZREMRANGEBYSCORE', key, '-inf', now)
|
||||||
|
redis.call('ZADD', key, expireAt, member)
|
||||||
|
|
||||||
|
if ttl > 0 then
|
||||||
|
redis.call('PEXPIRE', key, ttl)
|
||||||
|
end
|
||||||
|
|
||||||
|
local count = redis.call('ZCARD', key)
|
||||||
|
return count
|
||||||
|
`
|
||||||
|
|
||||||
|
const count = await this.client.eval(luaScript, 1, key, requestId, expireAt, now, ttl)
|
||||||
|
logger.database(
|
||||||
|
`🔢 Incremented concurrency for key ${apiKeyId}: ${count} (request ${requestId})`
|
||||||
|
)
|
||||||
return count
|
return count
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ Failed to increment concurrency:', error)
|
logger.error('❌ Failed to increment concurrency:', error)
|
||||||
@@ -1557,32 +1594,84 @@ class RedisClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 减少并发计数
|
// 刷新并发租约,防止长连接提前过期
|
||||||
async decrConcurrency(apiKeyId) {
|
async refreshConcurrencyLease(apiKeyId, requestId, leaseSeconds = null) {
|
||||||
try {
|
if (!requestId) {
|
||||||
const key = `concurrency:${apiKeyId}`
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { leaseSeconds: defaultLeaseSeconds, cleanupGraceSeconds } =
|
||||||
|
this._getConcurrencyConfig()
|
||||||
|
const lease = leaseSeconds || defaultLeaseSeconds
|
||||||
|
const key = `concurrency:${apiKeyId}`
|
||||||
|
const now = Date.now()
|
||||||
|
const expireAt = now + lease * 1000
|
||||||
|
const ttl = Math.max((lease + cleanupGraceSeconds) * 1000, 60000)
|
||||||
|
|
||||||
// 使用Lua脚本确保原子性操作,防止计数器变成负数
|
|
||||||
const luaScript = `
|
const luaScript = `
|
||||||
local key = KEYS[1]
|
local key = KEYS[1]
|
||||||
local current = tonumber(redis.call('get', key) or "0")
|
local member = ARGV[1]
|
||||||
|
local expireAt = tonumber(ARGV[2])
|
||||||
|
local now = tonumber(ARGV[3])
|
||||||
|
local ttl = tonumber(ARGV[4])
|
||||||
|
|
||||||
if current <= 0 then
|
local exists = redis.call('ZSCORE', key, member)
|
||||||
redis.call('del', key)
|
|
||||||
return 0
|
redis.call('ZREMRANGEBYSCORE', key, '-inf', now)
|
||||||
else
|
|
||||||
local new_value = redis.call('decr', key)
|
if exists then
|
||||||
if new_value <= 0 then
|
redis.call('ZADD', key, expireAt, member)
|
||||||
redis.call('del', key)
|
if ttl > 0 then
|
||||||
return 0
|
redis.call('PEXPIRE', key, ttl)
|
||||||
else
|
|
||||||
return new_value
|
|
||||||
end
|
end
|
||||||
|
return 1
|
||||||
end
|
end
|
||||||
|
|
||||||
|
return 0
|
||||||
`
|
`
|
||||||
|
|
||||||
const count = await this.client.eval(luaScript, 1, key)
|
const refreshed = await this.client.eval(luaScript, 1, key, requestId, expireAt, now, ttl)
|
||||||
logger.database(`🔢 Decremented concurrency for key ${apiKeyId}: ${count}`)
|
if (refreshed === 1) {
|
||||||
|
logger.debug(`🔄 Refreshed concurrency lease for key ${apiKeyId} (request ${requestId})`)
|
||||||
|
}
|
||||||
|
return refreshed
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to refresh concurrency lease:', error)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 减少并发计数
|
||||||
|
async decrConcurrency(apiKeyId, requestId) {
|
||||||
|
try {
|
||||||
|
const key = `concurrency:${apiKeyId}`
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
const luaScript = `
|
||||||
|
local key = KEYS[1]
|
||||||
|
local member = ARGV[1]
|
||||||
|
local now = tonumber(ARGV[2])
|
||||||
|
|
||||||
|
if member then
|
||||||
|
redis.call('ZREM', key, member)
|
||||||
|
end
|
||||||
|
|
||||||
|
redis.call('ZREMRANGEBYSCORE', key, '-inf', now)
|
||||||
|
|
||||||
|
local count = redis.call('ZCARD', key)
|
||||||
|
if count <= 0 then
|
||||||
|
redis.call('DEL', key)
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
|
||||||
|
return count
|
||||||
|
`
|
||||||
|
|
||||||
|
const count = await this.client.eval(luaScript, 1, key, requestId || '', now)
|
||||||
|
logger.database(
|
||||||
|
`🔢 Decremented concurrency for key ${apiKeyId}: ${count} (request ${requestId || 'n/a'})`
|
||||||
|
)
|
||||||
return count
|
return count
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ Failed to decrement concurrency:', error)
|
logger.error('❌ Failed to decrement concurrency:', error)
|
||||||
@@ -1594,7 +1683,17 @@ class RedisClient {
|
|||||||
async getConcurrency(apiKeyId) {
|
async getConcurrency(apiKeyId) {
|
||||||
try {
|
try {
|
||||||
const key = `concurrency:${apiKeyId}`
|
const key = `concurrency:${apiKeyId}`
|
||||||
const count = await this.client.get(key)
|
const now = Date.now()
|
||||||
|
|
||||||
|
const luaScript = `
|
||||||
|
local key = KEYS[1]
|
||||||
|
local now = tonumber(ARGV[1])
|
||||||
|
|
||||||
|
redis.call('ZREMRANGEBYSCORE', key, '-inf', now)
|
||||||
|
return redis.call('ZCARD', key)
|
||||||
|
`
|
||||||
|
|
||||||
|
const count = await this.client.eval(luaScript, 1, key, now)
|
||||||
return parseInt(count || 0)
|
return parseInt(count || 0)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ Failed to get concurrency:', error)
|
logger.error('❌ Failed to get concurrency:', error)
|
||||||
|
|||||||
@@ -547,6 +547,7 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
|
|||||||
weeklyOpusCostLimit,
|
weeklyOpusCostLimit,
|
||||||
tags,
|
tags,
|
||||||
activationDays, // 新增:激活后有效天数
|
activationDays, // 新增:激活后有效天数
|
||||||
|
activationUnit, // 新增:激活时间单位 (hours/days)
|
||||||
expirationMode, // 新增:过期模式
|
expirationMode, // 新增:过期模式
|
||||||
icon // 新增:图标
|
icon // 新增:图标
|
||||||
} = req.body
|
} = req.body
|
||||||
@@ -643,14 +644,23 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (expirationMode === 'activation') {
|
if (expirationMode === 'activation') {
|
||||||
|
// 验证激活时间单位
|
||||||
|
if (!activationUnit || !['hours', 'days'].includes(activationUnit)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Activation unit must be either "hours" or "days" when using activation mode'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证激活时间数值
|
||||||
if (
|
if (
|
||||||
!activationDays ||
|
!activationDays ||
|
||||||
!Number.isInteger(Number(activationDays)) ||
|
!Number.isInteger(Number(activationDays)) ||
|
||||||
Number(activationDays) < 1
|
Number(activationDays) < 1
|
||||||
) {
|
) {
|
||||||
return res
|
const unitText = activationUnit === 'hours' ? 'hours' : 'days'
|
||||||
.status(400)
|
return res.status(400).json({
|
||||||
.json({ error: 'Activation days must be a positive integer when using activation mode' })
|
error: `Activation ${unitText} must be a positive integer when using activation mode`
|
||||||
|
})
|
||||||
}
|
}
|
||||||
// 激活模式下不应该设置固定过期时间
|
// 激活模式下不应该设置固定过期时间
|
||||||
if (expiresAt) {
|
if (expiresAt) {
|
||||||
@@ -684,6 +694,7 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
|
|||||||
weeklyOpusCostLimit,
|
weeklyOpusCostLimit,
|
||||||
tags,
|
tags,
|
||||||
activationDays,
|
activationDays,
|
||||||
|
activationUnit,
|
||||||
expirationMode,
|
expirationMode,
|
||||||
icon
|
icon
|
||||||
})
|
})
|
||||||
@@ -724,6 +735,7 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
|||||||
weeklyOpusCostLimit,
|
weeklyOpusCostLimit,
|
||||||
tags,
|
tags,
|
||||||
activationDays,
|
activationDays,
|
||||||
|
activationUnit,
|
||||||
expirationMode,
|
expirationMode,
|
||||||
icon
|
icon
|
||||||
} = req.body
|
} = req.body
|
||||||
@@ -774,6 +786,7 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
|||||||
weeklyOpusCostLimit,
|
weeklyOpusCostLimit,
|
||||||
tags,
|
tags,
|
||||||
activationDays,
|
activationDays,
|
||||||
|
activationUnit,
|
||||||
expirationMode,
|
expirationMode,
|
||||||
icon
|
icon
|
||||||
})
|
})
|
||||||
@@ -2262,7 +2275,7 @@ router.delete('/claude-accounts/:accountId', authenticateAdmin, async (req, res)
|
|||||||
// 获取账户信息以检查是否在分组中
|
// 获取账户信息以检查是否在分组中
|
||||||
const account = await claudeAccountService.getAccount(accountId)
|
const account = await claudeAccountService.getAccount(accountId)
|
||||||
if (account && account.accountType === 'group') {
|
if (account && account.accountType === 'group') {
|
||||||
const groups = await accountGroupService.getAccountGroup(accountId)
|
const groups = await accountGroupService.getAccountGroups(accountId)
|
||||||
for (const group of groups) {
|
for (const group of groups) {
|
||||||
await accountGroupService.removeAccountFromGroup(accountId, group.id)
|
await accountGroupService.removeAccountFromGroup(accountId, group.id)
|
||||||
}
|
}
|
||||||
@@ -2652,7 +2665,7 @@ router.delete('/claude-console-accounts/:accountId', authenticateAdmin, async (r
|
|||||||
// 获取账户信息以检查是否在分组中
|
// 获取账户信息以检查是否在分组中
|
||||||
const account = await claudeConsoleAccountService.getAccount(accountId)
|
const account = await claudeConsoleAccountService.getAccount(accountId)
|
||||||
if (account && account.accountType === 'group') {
|
if (account && account.accountType === 'group') {
|
||||||
const groups = await accountGroupService.getAccountGroup(accountId)
|
const groups = await accountGroupService.getAccountGroups(accountId)
|
||||||
for (const group of groups) {
|
for (const group of groups) {
|
||||||
await accountGroupService.removeAccountFromGroup(accountId, group.id)
|
await accountGroupService.removeAccountFromGroup(accountId, group.id)
|
||||||
}
|
}
|
||||||
@@ -3887,7 +3900,7 @@ router.delete('/gemini-accounts/:accountId', authenticateAdmin, async (req, res)
|
|||||||
// 获取账户信息以检查是否在分组中
|
// 获取账户信息以检查是否在分组中
|
||||||
const account = await geminiAccountService.getAccount(accountId)
|
const account = await geminiAccountService.getAccount(accountId)
|
||||||
if (account && account.accountType === 'group') {
|
if (account && account.accountType === 'group') {
|
||||||
const groups = await accountGroupService.getAccountGroup(accountId)
|
const groups = await accountGroupService.getAccountGroups(accountId)
|
||||||
for (const group of groups) {
|
for (const group of groups) {
|
||||||
await accountGroupService.removeAccountFromGroup(accountId, group.id)
|
await accountGroupService.removeAccountFromGroup(accountId, group.id)
|
||||||
}
|
}
|
||||||
@@ -6869,6 +6882,16 @@ router.get('/openai-accounts', authenticateAdmin, async (req, res) => {
|
|||||||
const { platform, groupId } = req.query
|
const { platform, groupId } = req.query
|
||||||
let accounts = await openaiAccountService.getAllAccounts()
|
let accounts = await openaiAccountService.getAllAccounts()
|
||||||
|
|
||||||
|
// 缓存账户所属分组,避免重复查询
|
||||||
|
const accountGroupCache = new Map()
|
||||||
|
const fetchAccountGroups = async (accountId) => {
|
||||||
|
if (!accountGroupCache.has(accountId)) {
|
||||||
|
const groups = await accountGroupService.getAccountGroups(accountId)
|
||||||
|
accountGroupCache.set(accountId, groups || [])
|
||||||
|
}
|
||||||
|
return accountGroupCache.get(accountId)
|
||||||
|
}
|
||||||
|
|
||||||
// 根据查询参数进行筛选
|
// 根据查询参数进行筛选
|
||||||
if (platform && platform !== 'all' && platform !== 'openai') {
|
if (platform && platform !== 'all' && platform !== 'openai') {
|
||||||
// 如果指定了其他平台,返回空数组
|
// 如果指定了其他平台,返回空数组
|
||||||
@@ -6881,7 +6904,7 @@ router.get('/openai-accounts', authenticateAdmin, async (req, res) => {
|
|||||||
// 筛选未分组账户
|
// 筛选未分组账户
|
||||||
const filteredAccounts = []
|
const filteredAccounts = []
|
||||||
for (const account of accounts) {
|
for (const account of accounts) {
|
||||||
const groups = await accountGroupService.getAccountGroups(account.id)
|
const groups = await fetchAccountGroups(account.id)
|
||||||
if (!groups || groups.length === 0) {
|
if (!groups || groups.length === 0) {
|
||||||
filteredAccounts.push(account)
|
filteredAccounts.push(account)
|
||||||
}
|
}
|
||||||
@@ -6899,8 +6922,10 @@ router.get('/openai-accounts', authenticateAdmin, async (req, res) => {
|
|||||||
accounts.map(async (account) => {
|
accounts.map(async (account) => {
|
||||||
try {
|
try {
|
||||||
const usageStats = await redis.getAccountUsageStats(account.id, 'openai')
|
const usageStats = await redis.getAccountUsageStats(account.id, 'openai')
|
||||||
|
const groupInfos = await fetchAccountGroups(account.id)
|
||||||
return {
|
return {
|
||||||
...account,
|
...account,
|
||||||
|
groupInfos,
|
||||||
usage: {
|
usage: {
|
||||||
daily: usageStats.daily,
|
daily: usageStats.daily,
|
||||||
total: usageStats.total,
|
total: usageStats.total,
|
||||||
@@ -6909,8 +6934,10 @@ router.get('/openai-accounts', authenticateAdmin, async (req, res) => {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.debug(`Failed to get usage stats for OpenAI account ${account.id}:`, error)
|
logger.debug(`Failed to get usage stats for OpenAI account ${account.id}:`, error)
|
||||||
|
const groupInfos = await fetchAccountGroups(account.id)
|
||||||
return {
|
return {
|
||||||
...account,
|
...account,
|
||||||
|
groupInfos,
|
||||||
usage: {
|
usage: {
|
||||||
daily: { requests: 0, tokens: 0, allTokens: 0 },
|
daily: { requests: 0, tokens: 0, allTokens: 0 },
|
||||||
total: { requests: 0, tokens: 0, allTokens: 0 },
|
total: { requests: 0, tokens: 0, allTokens: 0 },
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ const redis = require('../models/redis')
|
|||||||
const logger = require('../utils/logger')
|
const logger = require('../utils/logger')
|
||||||
const apiKeyService = require('../services/apiKeyService')
|
const apiKeyService = require('../services/apiKeyService')
|
||||||
const CostCalculator = require('../utils/costCalculator')
|
const CostCalculator = require('../utils/costCalculator')
|
||||||
|
const claudeAccountService = require('../services/claudeAccountService')
|
||||||
|
const openaiAccountService = require('../services/openaiAccountService')
|
||||||
|
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
|
||||||
@@ -335,6 +337,50 @@ router.post('/api/user-stats', async (req, res) => {
|
|||||||
logger.warn(`Failed to get current usage for key ${keyId}:`, error)
|
logger.warn(`Failed to get current usage for key ${keyId}:`, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const boundAccountDetails = {}
|
||||||
|
|
||||||
|
const accountDetailTasks = []
|
||||||
|
|
||||||
|
if (fullKeyData.claudeAccountId) {
|
||||||
|
accountDetailTasks.push(
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const overview = await claudeAccountService.getAccountOverview(
|
||||||
|
fullKeyData.claudeAccountId
|
||||||
|
)
|
||||||
|
|
||||||
|
if (overview && overview.accountType === 'dedicated') {
|
||||||
|
boundAccountDetails.claude = overview
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`⚠️ Failed to load Claude account overview for key ${keyId}:`, error)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fullKeyData.openaiAccountId) {
|
||||||
|
accountDetailTasks.push(
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const overview = await openaiAccountService.getAccountOverview(
|
||||||
|
fullKeyData.openaiAccountId
|
||||||
|
)
|
||||||
|
|
||||||
|
if (overview && overview.accountType === 'dedicated') {
|
||||||
|
boundAccountDetails.openai = overview
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`⚠️ Failed to load OpenAI account overview for key ${keyId}:`, error)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accountDetailTasks.length > 0) {
|
||||||
|
await Promise.allSettled(accountDetailTasks)
|
||||||
|
}
|
||||||
|
|
||||||
// 构建响应数据(只返回该API Key自己的信息,确保不泄露其他信息)
|
// 构建响应数据(只返回该API Key自己的信息,确保不泄露其他信息)
|
||||||
const responseData = {
|
const responseData = {
|
||||||
id: keyId,
|
id: keyId,
|
||||||
@@ -399,7 +445,12 @@ router.post('/api/user-stats', async (req, res) => {
|
|||||||
geminiAccountId:
|
geminiAccountId:
|
||||||
fullKeyData.geminiAccountId && fullKeyData.geminiAccountId !== ''
|
fullKeyData.geminiAccountId && fullKeyData.geminiAccountId !== ''
|
||||||
? fullKeyData.geminiAccountId
|
? fullKeyData.geminiAccountId
|
||||||
: null
|
: null,
|
||||||
|
openaiAccountId:
|
||||||
|
fullKeyData.openaiAccountId && fullKeyData.openaiAccountId !== ''
|
||||||
|
? fullKeyData.openaiAccountId
|
||||||
|
: null,
|
||||||
|
details: Object.keys(boundAccountDetails).length > 0 ? boundAccountDetails : null
|
||||||
},
|
},
|
||||||
|
|
||||||
// 模型和客户端限制信息
|
// 模型和客户端限制信息
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ class ApiKeyService {
|
|||||||
weeklyOpusCostLimit = 0,
|
weeklyOpusCostLimit = 0,
|
||||||
tags = [],
|
tags = [],
|
||||||
activationDays = 0, // 新增:激活后有效天数(0表示不使用此功能)
|
activationDays = 0, // 新增:激活后有效天数(0表示不使用此功能)
|
||||||
|
activationUnit = 'days', // 新增:激活时间单位 'hours' 或 'days'
|
||||||
expirationMode = 'fixed', // 新增:过期模式 'fixed'(固定时间) 或 'activation'(首次使用后激活)
|
expirationMode = 'fixed', // 新增:过期模式 'fixed'(固定时间) 或 'activation'(首次使用后激活)
|
||||||
icon = '' // 新增:图标(base64编码)
|
icon = '' // 新增:图标(base64编码)
|
||||||
} = options
|
} = options
|
||||||
@@ -73,6 +74,7 @@ class ApiKeyService {
|
|||||||
weeklyOpusCostLimit: String(weeklyOpusCostLimit || 0),
|
weeklyOpusCostLimit: String(weeklyOpusCostLimit || 0),
|
||||||
tags: JSON.stringify(tags || []),
|
tags: JSON.stringify(tags || []),
|
||||||
activationDays: String(activationDays || 0), // 新增:激活后有效天数
|
activationDays: String(activationDays || 0), // 新增:激活后有效天数
|
||||||
|
activationUnit: activationUnit || 'days', // 新增:激活时间单位
|
||||||
expirationMode: expirationMode || 'fixed', // 新增:过期模式
|
expirationMode: expirationMode || 'fixed', // 新增:过期模式
|
||||||
isActivated: expirationMode === 'fixed' ? 'true' : 'false', // 根据模式决定激活状态
|
isActivated: expirationMode === 'fixed' ? 'true' : 'false', // 根据模式决定激活状态
|
||||||
activatedAt: expirationMode === 'fixed' ? new Date().toISOString() : '', // 激活时间
|
activatedAt: expirationMode === 'fixed' ? new Date().toISOString() : '', // 激活时间
|
||||||
@@ -117,6 +119,7 @@ class ApiKeyService {
|
|||||||
weeklyOpusCostLimit: parseFloat(keyData.weeklyOpusCostLimit || 0),
|
weeklyOpusCostLimit: parseFloat(keyData.weeklyOpusCostLimit || 0),
|
||||||
tags: JSON.parse(keyData.tags || '[]'),
|
tags: JSON.parse(keyData.tags || '[]'),
|
||||||
activationDays: parseInt(keyData.activationDays || 0),
|
activationDays: parseInt(keyData.activationDays || 0),
|
||||||
|
activationUnit: keyData.activationUnit || 'days',
|
||||||
expirationMode: keyData.expirationMode || 'fixed',
|
expirationMode: keyData.expirationMode || 'fixed',
|
||||||
isActivated: keyData.isActivated === 'true',
|
isActivated: keyData.isActivated === 'true',
|
||||||
activatedAt: keyData.activatedAt,
|
activatedAt: keyData.activatedAt,
|
||||||
@@ -152,8 +155,18 @@ class ApiKeyService {
|
|||||||
if (keyData.expirationMode === 'activation' && keyData.isActivated !== 'true') {
|
if (keyData.expirationMode === 'activation' && keyData.isActivated !== 'true') {
|
||||||
// 首次使用,需要激活
|
// 首次使用,需要激活
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const activationDays = parseInt(keyData.activationDays || 30) // 默认30天
|
const activationPeriod = parseInt(keyData.activationDays || 30) // 默认30
|
||||||
const expiresAt = new Date(now.getTime() + activationDays * 24 * 60 * 60 * 1000)
|
const activationUnit = keyData.activationUnit || 'days' // 默认天
|
||||||
|
|
||||||
|
// 根据单位计算过期时间
|
||||||
|
let milliseconds
|
||||||
|
if (activationUnit === 'hours') {
|
||||||
|
milliseconds = activationPeriod * 60 * 60 * 1000 // 小时转毫秒
|
||||||
|
} else {
|
||||||
|
milliseconds = activationPeriod * 24 * 60 * 60 * 1000 // 天转毫秒
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiresAt = new Date(now.getTime() + milliseconds)
|
||||||
|
|
||||||
// 更新激活状态和过期时间
|
// 更新激活状态和过期时间
|
||||||
keyData.isActivated = 'true'
|
keyData.isActivated = 'true'
|
||||||
@@ -167,7 +180,7 @@ class ApiKeyService {
|
|||||||
logger.success(
|
logger.success(
|
||||||
`🔓 API key activated: ${keyData.id} (${
|
`🔓 API key activated: ${keyData.id} (${
|
||||||
keyData.name
|
keyData.name
|
||||||
}), will expire in ${activationDays} days at ${expiresAt.toISOString()}`
|
}), will expire in ${activationPeriod} ${activationUnit} at ${expiresAt.toISOString()}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,6 +374,7 @@ class ApiKeyService {
|
|||||||
expirationMode: keyData.expirationMode || 'fixed',
|
expirationMode: keyData.expirationMode || 'fixed',
|
||||||
isActivated: keyData.isActivated === 'true',
|
isActivated: keyData.isActivated === 'true',
|
||||||
activationDays: parseInt(keyData.activationDays || 0),
|
activationDays: parseInt(keyData.activationDays || 0),
|
||||||
|
activationUnit: keyData.activationUnit || 'days',
|
||||||
activatedAt: keyData.activatedAt || null,
|
activatedAt: keyData.activatedAt || null,
|
||||||
claudeAccountId: keyData.claudeAccountId,
|
claudeAccountId: keyData.claudeAccountId,
|
||||||
claudeConsoleAccountId: keyData.claudeConsoleAccountId,
|
claudeConsoleAccountId: keyData.claudeConsoleAccountId,
|
||||||
@@ -432,6 +446,7 @@ class ApiKeyService {
|
|||||||
key.dailyCost = (await redis.getDailyCost(key.id)) || 0
|
key.dailyCost = (await redis.getDailyCost(key.id)) || 0
|
||||||
key.weeklyOpusCost = (await redis.getWeeklyOpusCost(key.id)) || 0
|
key.weeklyOpusCost = (await redis.getWeeklyOpusCost(key.id)) || 0
|
||||||
key.activationDays = parseInt(key.activationDays || 0)
|
key.activationDays = parseInt(key.activationDays || 0)
|
||||||
|
key.activationUnit = key.activationUnit || 'days'
|
||||||
key.expirationMode = key.expirationMode || 'fixed'
|
key.expirationMode = key.expirationMode || 'fixed'
|
||||||
key.isActivated = key.isActivated === 'true'
|
key.isActivated = key.isActivated === 'true'
|
||||||
key.activatedAt = key.activatedAt || null
|
key.activatedAt = key.activatedAt || null
|
||||||
@@ -541,6 +556,7 @@ class ApiKeyService {
|
|||||||
'permissions',
|
'permissions',
|
||||||
'expiresAt',
|
'expiresAt',
|
||||||
'activationDays', // 新增:激活后有效天数
|
'activationDays', // 新增:激活后有效天数
|
||||||
|
'activationUnit', // 新增:激活时间单位
|
||||||
'expirationMode', // 新增:过期模式
|
'expirationMode', // 新增:过期模式
|
||||||
'isActivated', // 新增:是否已激活
|
'isActivated', // 新增:是否已激活
|
||||||
'activatedAt', // 新增:激活时间
|
'activatedAt', // 新增:激活时间
|
||||||
|
|||||||
@@ -518,6 +518,59 @@ class ClaudeAccountService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 📋 获取单个账号的概要信息(用于前端展示会话窗口等状态)
|
||||||
|
async getAccountOverview(accountId) {
|
||||||
|
try {
|
||||||
|
const accountData = await redis.getClaudeAccount(accountId)
|
||||||
|
|
||||||
|
if (!accountData || Object.keys(accountData).length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const [sessionWindowInfo, rateLimitInfo] = await Promise.all([
|
||||||
|
this.getSessionWindowInfo(accountId),
|
||||||
|
this.getAccountRateLimitInfo(accountId)
|
||||||
|
])
|
||||||
|
|
||||||
|
const sessionWindow = sessionWindowInfo || {
|
||||||
|
hasActiveWindow: false,
|
||||||
|
windowStart: null,
|
||||||
|
windowEnd: null,
|
||||||
|
progress: 0,
|
||||||
|
remainingTime: null,
|
||||||
|
lastRequestTime: accountData.lastRequestTime || null,
|
||||||
|
sessionWindowStatus: accountData.sessionWindowStatus || null
|
||||||
|
}
|
||||||
|
|
||||||
|
const rateLimitStatus = rateLimitInfo
|
||||||
|
? {
|
||||||
|
isRateLimited: !!rateLimitInfo.isRateLimited,
|
||||||
|
rateLimitedAt: rateLimitInfo.rateLimitedAt || null,
|
||||||
|
minutesRemaining: rateLimitInfo.minutesRemaining || 0,
|
||||||
|
rateLimitEndAt: rateLimitInfo.rateLimitEndAt || null
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
isRateLimited: false,
|
||||||
|
rateLimitedAt: null,
|
||||||
|
minutesRemaining: 0,
|
||||||
|
rateLimitEndAt: null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: accountData.id,
|
||||||
|
accountType: accountData.accountType || 'shared',
|
||||||
|
platform: accountData.platform || 'claude',
|
||||||
|
isActive: accountData.isActive === 'true',
|
||||||
|
schedulable: accountData.schedulable !== 'false',
|
||||||
|
sessionWindow,
|
||||||
|
rateLimitStatus
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to build Claude account overview for ${accountId}:`, error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 📝 更新Claude账户
|
// 📝 更新Claude账户
|
||||||
async updateAccount(accountId, updates) {
|
async updateAccount(accountId, updates) {
|
||||||
try {
|
try {
|
||||||
@@ -546,6 +599,7 @@ class ClaudeAccountService {
|
|||||||
'unifiedClientId'
|
'unifiedClientId'
|
||||||
]
|
]
|
||||||
const updatedData = { ...accountData }
|
const updatedData = { ...accountData }
|
||||||
|
let shouldClearAutoStopFields = false
|
||||||
|
|
||||||
// 检查是否新增了 refresh token
|
// 检查是否新增了 refresh token
|
||||||
const oldRefreshToken = this._decryptSensitiveData(accountData.refreshToken)
|
const oldRefreshToken = this._decryptSensitiveData(accountData.refreshToken)
|
||||||
@@ -616,6 +670,7 @@ class ClaudeAccountService {
|
|||||||
// 兼容旧的标记(逐步迁移)
|
// 兼容旧的标记(逐步迁移)
|
||||||
delete updatedData.autoStoppedAt
|
delete updatedData.autoStoppedAt
|
||||||
delete updatedData.stoppedReason
|
delete updatedData.stoppedReason
|
||||||
|
shouldClearAutoStopFields = true
|
||||||
|
|
||||||
// 如果是手动启用调度,记录日志
|
// 如果是手动启用调度,记录日志
|
||||||
if (updates.schedulable === true || updates.schedulable === 'true') {
|
if (updates.schedulable === true || updates.schedulable === 'true') {
|
||||||
@@ -647,6 +702,18 @@ class ClaudeAccountService {
|
|||||||
|
|
||||||
await redis.setClaudeAccount(accountId, updatedData)
|
await redis.setClaudeAccount(accountId, updatedData)
|
||||||
|
|
||||||
|
if (shouldClearAutoStopFields) {
|
||||||
|
const fieldsToRemove = [
|
||||||
|
'rateLimitAutoStopped',
|
||||||
|
'fiveHourAutoStopped',
|
||||||
|
'fiveHourStoppedAt',
|
||||||
|
'tempErrorAutoStopped',
|
||||||
|
'autoStoppedAt',
|
||||||
|
'stoppedReason'
|
||||||
|
]
|
||||||
|
await this._removeAccountFields(accountId, fieldsToRemove, 'manual_schedule_update')
|
||||||
|
}
|
||||||
|
|
||||||
logger.success(`📝 Updated Claude account: ${accountId}`)
|
logger.success(`📝 Updated Claude account: ${accountId}`)
|
||||||
|
|
||||||
return { success: true }
|
return { success: true }
|
||||||
@@ -1346,6 +1413,9 @@ class ClaudeAccountService {
|
|||||||
const now = new Date()
|
const now = new Date()
|
||||||
const currentTime = now.getTime()
|
const currentTime = now.getTime()
|
||||||
|
|
||||||
|
let shouldClearSessionStatus = false
|
||||||
|
let shouldClearFiveHourFlags = false
|
||||||
|
|
||||||
// 检查当前是否有活跃的会话窗口
|
// 检查当前是否有活跃的会话窗口
|
||||||
if (accountData.sessionWindowStart && accountData.sessionWindowEnd) {
|
if (accountData.sessionWindowStart && accountData.sessionWindowEnd) {
|
||||||
const windowEnd = new Date(accountData.sessionWindowEnd).getTime()
|
const windowEnd = new Date(accountData.sessionWindowEnd).getTime()
|
||||||
@@ -1376,6 +1446,7 @@ class ClaudeAccountService {
|
|||||||
if (accountData.sessionWindowStatus) {
|
if (accountData.sessionWindowStatus) {
|
||||||
delete accountData.sessionWindowStatus
|
delete accountData.sessionWindowStatus
|
||||||
delete accountData.sessionWindowStatusUpdatedAt
|
delete accountData.sessionWindowStatusUpdatedAt
|
||||||
|
shouldClearSessionStatus = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果账户因为5小时限制被自动停止,现在恢复调度
|
// 如果账户因为5小时限制被自动停止,现在恢复调度
|
||||||
@@ -1386,6 +1457,7 @@ class ClaudeAccountService {
|
|||||||
accountData.schedulable = 'true'
|
accountData.schedulable = 'true'
|
||||||
delete accountData.fiveHourAutoStopped
|
delete accountData.fiveHourAutoStopped
|
||||||
delete accountData.fiveHourStoppedAt
|
delete accountData.fiveHourStoppedAt
|
||||||
|
shouldClearFiveHourFlags = true
|
||||||
|
|
||||||
// 发送Webhook通知
|
// 发送Webhook通知
|
||||||
try {
|
try {
|
||||||
@@ -1404,6 +1476,17 @@ class ClaudeAccountService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (shouldClearSessionStatus || shouldClearFiveHourFlags) {
|
||||||
|
const fieldsToRemove = []
|
||||||
|
if (shouldClearFiveHourFlags) {
|
||||||
|
fieldsToRemove.push('fiveHourAutoStopped', 'fiveHourStoppedAt')
|
||||||
|
}
|
||||||
|
if (shouldClearSessionStatus) {
|
||||||
|
fieldsToRemove.push('sessionWindowStatus', 'sessionWindowStatusUpdatedAt')
|
||||||
|
}
|
||||||
|
await this._removeAccountFields(accountId, fieldsToRemove, 'session_window_refresh')
|
||||||
|
}
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`🕐 Created new session window for account ${accountData.name} (${accountId}): ${windowStart.toISOString()} - ${windowEnd.toISOString()} (from current time)`
|
`🕐 Created new session window for account ${accountData.name} (${accountId}): ${windowStart.toISOString()} - ${windowEnd.toISOString()} (from current time)`
|
||||||
)
|
)
|
||||||
@@ -2433,6 +2516,12 @@ class ClaudeAccountService {
|
|||||||
// 保存更新
|
// 保存更新
|
||||||
await redis.setClaudeAccount(account.id, updatedAccountData)
|
await redis.setClaudeAccount(account.id, updatedAccountData)
|
||||||
|
|
||||||
|
const fieldsToRemove = ['fiveHourAutoStopped', 'fiveHourStoppedAt']
|
||||||
|
if (newWindowStart && newWindowEnd) {
|
||||||
|
fieldsToRemove.push('sessionWindowStatus', 'sessionWindowStatusUpdatedAt')
|
||||||
|
}
|
||||||
|
await this._removeAccountFields(account.id, fieldsToRemove, 'five_hour_recovery_task')
|
||||||
|
|
||||||
result.recovered++
|
result.recovered++
|
||||||
result.accounts.push({
|
result.accounts.push({
|
||||||
id: latestAccount.id,
|
id: latestAccount.id,
|
||||||
@@ -2488,6 +2577,31 @@ class ClaudeAccountService {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async _removeAccountFields(accountId, fields = [], context = 'general_cleanup') {
|
||||||
|
if (!Array.isArray(fields) || fields.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredFields = fields.filter((field) => typeof field === 'string' && field.trim())
|
||||||
|
if (filteredFields.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountKey = `claude:account:${accountId}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
await redis.client.hdel(accountKey, ...filteredFields)
|
||||||
|
logger.debug(
|
||||||
|
`🧹 已在 ${context} 阶段为账号 ${accountId} 删除字段 [${filteredFields.join(', ')}]`
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`❌ 无法在 ${context} 阶段为账号 ${accountId} 删除字段 [${filteredFields.join(', ')}]:`,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = new ClaudeAccountService()
|
module.exports = new ClaudeAccountService()
|
||||||
|
|||||||
@@ -808,6 +808,47 @@ async function getAllAccounts() {
|
|||||||
return accounts
|
return accounts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取单个账户的概要信息(用于外部展示基本状态)
|
||||||
|
async function getAccountOverview(accountId) {
|
||||||
|
const client = redisClient.getClientSafe()
|
||||||
|
const accountData = await client.hgetall(`${OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`)
|
||||||
|
|
||||||
|
if (!accountData || Object.keys(accountData).length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const codexUsage = buildCodexUsageSnapshot(accountData)
|
||||||
|
const rateLimitInfo = await getAccountRateLimitInfo(accountId)
|
||||||
|
|
||||||
|
if (accountData.proxy) {
|
||||||
|
try {
|
||||||
|
accountData.proxy = JSON.parse(accountData.proxy)
|
||||||
|
} catch (error) {
|
||||||
|
accountData.proxy = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scopes =
|
||||||
|
accountData.scopes && accountData.scopes.trim() ? accountData.scopes.split(' ') : []
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: accountData.id,
|
||||||
|
accountType: accountData.accountType || 'shared',
|
||||||
|
platform: accountData.platform || 'openai',
|
||||||
|
isActive: accountData.isActive === 'true',
|
||||||
|
schedulable: accountData.schedulable !== 'false',
|
||||||
|
rateLimitStatus: rateLimitInfo || {
|
||||||
|
status: 'normal',
|
||||||
|
isRateLimited: false,
|
||||||
|
rateLimitedAt: null,
|
||||||
|
rateLimitResetAt: null,
|
||||||
|
minutesRemaining: 0
|
||||||
|
},
|
||||||
|
codexUsage,
|
||||||
|
scopes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 选择可用账户(支持专属和共享账户)
|
// 选择可用账户(支持专属和共享账户)
|
||||||
async function selectAvailableAccount(apiKeyId, sessionHash = null) {
|
async function selectAvailableAccount(apiKeyId, sessionHash = null) {
|
||||||
// 首先检查是否有粘性会话
|
// 首先检查是否有粘性会话
|
||||||
@@ -1175,6 +1216,7 @@ async function updateCodexUsageSnapshot(accountId, usageSnapshot) {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
createAccount,
|
createAccount,
|
||||||
getAccount,
|
getAccount,
|
||||||
|
getAccountOverview,
|
||||||
updateAccount,
|
updateAccount,
|
||||||
deleteAccount,
|
deleteAccount,
|
||||||
getAllAccounts,
|
getAllAccounts,
|
||||||
|
|||||||
@@ -492,11 +492,11 @@
|
|||||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
<span v-if="form.expirationMode === 'fixed'">
|
<span v-if="form.expirationMode === 'fixed'">
|
||||||
<i class="fas fa-info-circle mr-1" />
|
<i class="fas fa-info-circle mr-1" />
|
||||||
固定时间模式:Key 创建后立即生效,按设定时间过期
|
固定时间模式:Key 创建后立即生效,按设定时间过期(支持小时和天数)
|
||||||
</span>
|
</span>
|
||||||
<span v-else>
|
<span v-else>
|
||||||
<i class="fas fa-info-circle mr-1" />
|
<i class="fas fa-info-circle mr-1" />
|
||||||
激活模式:Key 首次使用时激活,激活后按设定天数过期(适合批量销售)
|
激活模式:Key 首次使用时激活,激活后按设定时间过期(支持小时和天数,适合批量销售)
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -509,6 +509,10 @@
|
|||||||
@change="updateExpireAt"
|
@change="updateExpireAt"
|
||||||
>
|
>
|
||||||
<option value="">永不过期</option>
|
<option value="">永不过期</option>
|
||||||
|
<option value="1h">1 小时</option>
|
||||||
|
<option value="3h">3 小时</option>
|
||||||
|
<option value="6h">6 小时</option>
|
||||||
|
<option value="12h">12 小时</option>
|
||||||
<option value="1d">1 天</option>
|
<option value="1d">1 天</option>
|
||||||
<option value="7d">7 天</option>
|
<option value="7d">7 天</option>
|
||||||
<option value="30d">30 天</option>
|
<option value="30d">30 天</option>
|
||||||
@@ -537,27 +541,36 @@
|
|||||||
<input
|
<input
|
||||||
v-model.number="form.activationDays"
|
v-model.number="form.activationDays"
|
||||||
class="form-input flex-1 border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
class="form-input flex-1 border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||||
max="3650"
|
:max="form.activationUnit === 'hours' ? 8760 : 3650"
|
||||||
min="1"
|
min="1"
|
||||||
placeholder="输入天数"
|
:placeholder="form.activationUnit === 'hours' ? '输入小时数' : '输入天数'"
|
||||||
type="number"
|
type="number"
|
||||||
/>
|
/>
|
||||||
<span class="text-sm text-gray-600 dark:text-gray-400">天</span>
|
<select
|
||||||
|
v-model="form.activationUnit"
|
||||||
|
class="form-input w-20 border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||||
|
@change="updateActivationValue"
|
||||||
|
>
|
||||||
|
<option value="hours">小时</option>
|
||||||
|
<option value="days">天</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 flex flex-wrap gap-2">
|
<div class="mt-2 flex flex-wrap gap-2">
|
||||||
<button
|
<button
|
||||||
v-for="days in [30, 90, 180, 365]"
|
v-for="value in getQuickTimeOptions()"
|
||||||
:key="days"
|
:key="value.value"
|
||||||
class="rounded-md border border-gray-300 px-3 py-1 text-xs hover:bg-gray-100 dark:border-gray-600 dark:hover:bg-gray-700"
|
class="rounded-md border border-gray-300 px-3 py-1 text-xs hover:bg-gray-100 dark:border-gray-600 dark:hover:bg-gray-700"
|
||||||
type="button"
|
type="button"
|
||||||
@click="form.activationDays = days"
|
@click="form.activationDays = value.value"
|
||||||
>
|
>
|
||||||
{{ days }}天
|
{{ value.label }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
<i class="fas fa-clock mr-1" />
|
<i class="fas fa-clock mr-1" />
|
||||||
Key 将在首次使用后激活,激活后 {{ form.activationDays || 30 }} 天过期
|
Key 将在首次使用后激活,激活后
|
||||||
|
{{ form.activationDays || (form.activationUnit === 'hours' ? 24 : 30) }}
|
||||||
|
{{ form.activationUnit === 'hours' ? '小时' : '天' }}过期
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -917,6 +930,7 @@ const form = reactive({
|
|||||||
expiresAt: null,
|
expiresAt: null,
|
||||||
expirationMode: 'fixed', // 过期模式:fixed(固定) 或 activation(激活)
|
expirationMode: 'fixed', // 过期模式:fixed(固定) 或 activation(激活)
|
||||||
activationDays: 30, // 激活后有效天数
|
activationDays: 30, // 激活后有效天数
|
||||||
|
activationUnit: 'days', // 激活时间单位:hours 或 days
|
||||||
permissions: 'all',
|
permissions: 'all',
|
||||||
claudeAccountId: '',
|
claudeAccountId: '',
|
||||||
geminiAccountId: '',
|
geminiAccountId: '',
|
||||||
@@ -1185,6 +1199,40 @@ const removeTag = (index) => {
|
|||||||
form.tags.splice(index, 1)
|
form.tags.splice(index, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取快捷时间选项
|
||||||
|
const getQuickTimeOptions = () => {
|
||||||
|
if (form.activationUnit === 'hours') {
|
||||||
|
return [
|
||||||
|
{ value: 1, label: '1小时' },
|
||||||
|
{ value: 3, label: '3小时' },
|
||||||
|
{ value: 6, label: '6小时' },
|
||||||
|
{ value: 12, label: '12小时' }
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
return [
|
||||||
|
{ value: 30, label: '30天' },
|
||||||
|
{ value: 90, label: '90天' },
|
||||||
|
{ value: 180, label: '180天' },
|
||||||
|
{ value: 365, label: '365天' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单位变化时更新数值
|
||||||
|
const updateActivationValue = () => {
|
||||||
|
if (form.activationUnit === 'hours') {
|
||||||
|
// 从天切换到小时,设置一个合理的默认值
|
||||||
|
if (form.activationDays > 24) {
|
||||||
|
form.activationDays = 24
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 从小时切换到天,设置一个合理的默认值
|
||||||
|
if (form.activationDays < 1) {
|
||||||
|
form.activationDays = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 创建 API Key
|
// 创建 API Key
|
||||||
const createApiKey = async () => {
|
const createApiKey = async () => {
|
||||||
// 验证表单
|
// 验证表单
|
||||||
@@ -1260,6 +1308,7 @@ const createApiKey = async () => {
|
|||||||
expiresAt: form.expirationMode === 'fixed' ? form.expiresAt || undefined : undefined,
|
expiresAt: form.expirationMode === 'fixed' ? form.expiresAt || undefined : undefined,
|
||||||
expirationMode: form.expirationMode,
|
expirationMode: form.expirationMode,
|
||||||
activationDays: form.expirationMode === 'activation' ? form.activationDays : undefined,
|
activationDays: form.expirationMode === 'activation' ? form.activationDays : undefined,
|
||||||
|
activationUnit: form.expirationMode === 'activation' ? form.activationUnit : undefined,
|
||||||
permissions: form.permissions,
|
permissions: form.permissions,
|
||||||
tags: form.tags.length > 0 ? form.tags : undefined,
|
tags: form.tags.length > 0 ? form.tags : undefined,
|
||||||
enableModelRestriction: form.enableModelRestriction,
|
enableModelRestriction: form.enableModelRestriction,
|
||||||
|
|||||||
@@ -46,7 +46,9 @@
|
|||||||
<i class="fas fa-pause-circle mr-1 text-blue-500" />
|
<i class="fas fa-pause-circle mr-1 text-blue-500" />
|
||||||
未激活
|
未激活
|
||||||
<span class="ml-2 text-xs font-normal text-gray-600">
|
<span class="ml-2 text-xs font-normal text-gray-600">
|
||||||
(激活后 {{ apiKey.activationDays || 30 }} 天过期)
|
(激活后
|
||||||
|
{{ apiKey.activationDays || (apiKey.activationUnit === 'hours' ? 24 : 30) }}
|
||||||
|
{{ apiKey.activationUnit === 'hours' ? '小时' : '天' }}过期)
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<!-- 已设置过期时间 -->
|
<!-- 已设置过期时间 -->
|
||||||
@@ -89,11 +91,15 @@
|
|||||||
@click="handleActivateNow"
|
@click="handleActivateNow"
|
||||||
>
|
>
|
||||||
<i class="fas fa-rocket mr-2" />
|
<i class="fas fa-rocket mr-2" />
|
||||||
立即激活 (激活后 {{ apiKey.activationDays || 30 }} 天过期)
|
立即激活 (激活后
|
||||||
|
{{ apiKey.activationDays || (apiKey.activationUnit === 'hours' ? 24 : 30) }}
|
||||||
|
{{ apiKey.activationUnit === 'hours' ? '小时' : '天' }}过期)
|
||||||
</button>
|
</button>
|
||||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
<i class="fas fa-info-circle mr-1" />
|
<i class="fas fa-info-circle mr-1" />
|
||||||
点击立即激活此 API Key,激活后将在 {{ apiKey.activationDays || 30 }} 天后过期
|
点击立即激活此 API Key,激活后将在
|
||||||
|
{{ apiKey.activationDays || (apiKey.activationUnit === 'hours' ? 24 : 30) }}
|
||||||
|
{{ apiKey.activationUnit === 'hours' ? '小时' : '天' }}后过期
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -400,14 +406,14 @@ const handleActivateNow = async () => {
|
|||||||
if (window.showConfirm) {
|
if (window.showConfirm) {
|
||||||
confirmed = await window.showConfirm(
|
confirmed = await window.showConfirm(
|
||||||
'激活 API Key',
|
'激活 API Key',
|
||||||
`确定要立即激活此 API Key 吗?激活后将在 ${props.apiKey.activationDays || 30} 天后自动过期。`,
|
`确定要立即激活此 API Key 吗?激活后将在 ${props.apiKey.activationDays || (props.apiKey.activationUnit === 'hours' ? 24 : 30)} ${props.apiKey.activationUnit === 'hours' ? '小时' : '天'}后自动过期。`,
|
||||||
'确定激活',
|
'确定激活',
|
||||||
'取消'
|
'取消'
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// 降级方案
|
// 降级方案
|
||||||
confirmed = confirm(
|
confirmed = confirm(
|
||||||
`确定要立即激活此 API Key 吗?激活后将在 ${props.apiKey.activationDays || 30} 天后自动过期。`
|
`确定要立即激活此 API Key 吗?激活后将在 ${props.apiKey.activationDays || (props.apiKey.activationUnit === 'hours' ? 24 : 30)} ${props.apiKey.activationUnit === 'hours' ? '小时' : '天'}后自动过期。`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="flex h-full flex-col gap-4 md:gap-6">
|
||||||
<!-- 限制配置 / 聚合模式提示 -->
|
<!-- 限制配置 / 聚合模式提示 -->
|
||||||
<div class="card p-4 md:p-6">
|
<div class="card flex h-full flex-col p-4 md:p-6">
|
||||||
<h3
|
<h3
|
||||||
class="mb-3 flex items-center text-lg font-bold text-gray-900 dark:text-gray-100 md:mb-4 md:text-xl"
|
class="mb-3 flex items-center text-lg font-bold text-gray-900 dark:text-gray-100 md:mb-4 md:text-xl"
|
||||||
>
|
>
|
||||||
@@ -226,7 +226,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 其他限制信息 -->
|
<!-- 其他限制信息 -->
|
||||||
<div class="space-y-2 border-t border-gray-100 pt-2 dark:border-gray-700">
|
<div class="space-y-4 border-t border-gray-100 pt-3 dark:border-gray-700">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">并发限制</span>
|
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">并发限制</span>
|
||||||
<span class="text-sm font-medium text-gray-900 md:text-base">
|
<span class="text-sm font-medium text-gray-900 md:text-base">
|
||||||
@@ -241,13 +241,7 @@
|
|||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">模型限制</span>
|
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">模型限制</span>
|
||||||
<span class="text-sm font-medium text-gray-900 md:text-base">
|
<span class="text-sm font-medium text-gray-900 md:text-base">
|
||||||
<span
|
<span v-if="hasModelRestrictions" class="text-orange-600">
|
||||||
v-if="
|
|
||||||
statsData.restrictions.enableModelRestriction &&
|
|
||||||
statsData.restrictions.restrictedModels.length > 0
|
|
||||||
"
|
|
||||||
class="text-orange-600"
|
|
||||||
>
|
|
||||||
<i class="fas fa-exclamation-triangle mr-1 text-xs md:text-sm" />
|
<i class="fas fa-exclamation-triangle mr-1 text-xs md:text-sm" />
|
||||||
限制 {{ statsData.restrictions.restrictedModels.length }} 个模型
|
限制 {{ statsData.restrictions.restrictedModels.length }} 个模型
|
||||||
</span>
|
</span>
|
||||||
@@ -257,16 +251,11 @@
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">客户端限制</span>
|
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">客户端限制</span>
|
||||||
<span class="text-sm font-medium text-gray-900 md:text-base">
|
<span class="text-sm font-medium text-gray-900 md:text-base">
|
||||||
<span
|
<span v-if="hasClientRestrictions" class="text-orange-600">
|
||||||
v-if="
|
|
||||||
statsData.restrictions.enableClientRestriction &&
|
|
||||||
statsData.restrictions.allowedClients.length > 0
|
|
||||||
"
|
|
||||||
class="text-orange-600"
|
|
||||||
>
|
|
||||||
<i class="fas fa-exclamation-triangle mr-1 text-xs md:text-sm" />
|
<i class="fas fa-exclamation-triangle mr-1 text-xs md:text-sm" />
|
||||||
限 {{ statsData.restrictions.allowedClients.length }} 种客户端使用
|
限 {{ statsData.restrictions.allowedClients.length }} 种客户端使用
|
||||||
</span>
|
</span>
|
||||||
@@ -276,20 +265,26 @@
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="hasClientRestrictions"
|
||||||
|
class="flex flex-wrap gap-2 rounded-lg bg-blue-50 p-2 dark:bg-blue-900/20 md:p-3"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-for="client in statsData.restrictions.allowedClients"
|
||||||
|
:key="client"
|
||||||
|
class="flex items-center gap-1 rounded-full bg-white px-2 py-1 text-xs text-blue-700 shadow-sm dark:bg-slate-900 dark:text-blue-300 md:text-sm"
|
||||||
|
>
|
||||||
|
<i class="fas fa-id-badge" />
|
||||||
|
{{ client }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 详细限制信息 -->
|
<!-- 详细限制信息 -->
|
||||||
<div
|
<div v-if="hasModelRestrictions" class="card p-4 md:p-6">
|
||||||
v-if="
|
|
||||||
(statsData.restrictions.enableModelRestriction &&
|
|
||||||
statsData.restrictions.restrictedModels.length > 0) ||
|
|
||||||
(statsData.restrictions.enableClientRestriction &&
|
|
||||||
statsData.restrictions.allowedClients.length > 0)
|
|
||||||
"
|
|
||||||
class="card mt-4 p-4 md:mt-6 md:p-6"
|
|
||||||
>
|
|
||||||
<h3
|
<h3
|
||||||
class="mb-3 flex items-center text-lg font-bold text-gray-900 dark:text-gray-100 md:mb-4 md:text-xl"
|
class="mb-3 flex items-center text-lg font-bold text-gray-900 dark:text-gray-100 md:mb-4 md:text-xl"
|
||||||
>
|
>
|
||||||
@@ -297,13 +292,7 @@
|
|||||||
详细限制信息
|
详细限制信息
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:gap-6 lg:grid-cols-2">
|
|
||||||
<!-- 模型限制详情 -->
|
|
||||||
<div
|
<div
|
||||||
v-if="
|
|
||||||
statsData.restrictions.enableModelRestriction &&
|
|
||||||
statsData.restrictions.restrictedModels.length > 0
|
|
||||||
"
|
|
||||||
class="rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-800 dark:bg-amber-900/20 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
|
<h4
|
||||||
@@ -327,42 +316,12 @@
|
|||||||
此 API Key 不能访问以上列出的模型
|
此 API Key 不能访问以上列出的模型
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 客户端限制详情 -->
|
|
||||||
<div
|
|
||||||
v-if="
|
|
||||||
statsData.restrictions.enableClientRestriction &&
|
|
||||||
statsData.restrictions.allowedClients.length > 0
|
|
||||||
"
|
|
||||||
class="rounded-lg border border-blue-200 bg-blue-50 p-3 dark:border-blue-800 dark:bg-blue-900/20 md:p-4"
|
|
||||||
>
|
|
||||||
<h4
|
|
||||||
class="mb-2 flex items-center text-sm font-bold text-blue-800 dark:text-blue-300 md:mb-3 md:text-base"
|
|
||||||
>
|
|
||||||
<i class="fas fa-desktop mr-1 text-xs md:mr-2 md:text-sm" />
|
|
||||||
允许的客户端
|
|
||||||
</h4>
|
|
||||||
<div class="space-y-1 md:space-y-2">
|
|
||||||
<div
|
|
||||||
v-for="client in statsData.restrictions.allowedClients"
|
|
||||||
:key="client"
|
|
||||||
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 dark:text-gray-200">{{ client }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { useApiStatsStore } from '@/stores/apistats'
|
import { useApiStatsStore } from '@/stores/apistats'
|
||||||
import WindowCountdown from '@/components/apikeys/WindowCountdown.vue'
|
import WindowCountdown from '@/components/apikeys/WindowCountdown.vue'
|
||||||
@@ -370,6 +329,26 @@ import WindowCountdown from '@/components/apikeys/WindowCountdown.vue'
|
|||||||
const apiStatsStore = useApiStatsStore()
|
const apiStatsStore = useApiStatsStore()
|
||||||
const { statsData, multiKeyMode, aggregatedStats, invalidKeys } = storeToRefs(apiStatsStore)
|
const { statsData, multiKeyMode, aggregatedStats, invalidKeys } = storeToRefs(apiStatsStore)
|
||||||
|
|
||||||
|
const hasModelRestrictions = computed(() => {
|
||||||
|
const restriction = statsData.value?.restrictions
|
||||||
|
if (!restriction) return false
|
||||||
|
return (
|
||||||
|
restriction.enableModelRestriction === true &&
|
||||||
|
Array.isArray(restriction.restrictedModels) &&
|
||||||
|
restriction.restrictedModels.length > 0
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasClientRestrictions = computed(() => {
|
||||||
|
const restriction = statsData.value?.restrictions
|
||||||
|
if (!restriction) return false
|
||||||
|
return (
|
||||||
|
restriction.enableClientRestriction === true &&
|
||||||
|
Array.isArray(restriction.allowedClients) &&
|
||||||
|
restriction.allowedClients.length > 0
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
// 获取每日费用进度
|
// 获取每日费用进度
|
||||||
const getDailyCostProgress = () => {
|
const getDailyCostProgress = () => {
|
||||||
if (!statsData.value.limits.dailyCostLimit || statsData.value.limits.dailyCostLimit === 0)
|
if (!statsData.value.limits.dailyCostLimit || statsData.value.limits.dailyCostLimit === 0)
|
||||||
|
|||||||
@@ -1,207 +1,274 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="mb-6 grid grid-cols-1 gap-4 md:mb-8 md:gap-6 lg:grid-cols-2">
|
<div class="space-y-6 md:space-y-8">
|
||||||
<!-- API Key 基本信息 / 批量查询概要 -->
|
<div
|
||||||
<div class="card p-4 md:p-6">
|
class="grid grid-cols-1 items-stretch gap-4 md:gap-6 xl:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)]"
|
||||||
<h3
|
|
||||||
class="mb-3 flex items-center text-lg font-bold text-gray-900 dark:text-gray-100 md:mb-4 md:text-xl"
|
|
||||||
>
|
>
|
||||||
|
<!-- 基础信息 / 批量概要 -->
|
||||||
|
<div class="card-section">
|
||||||
|
<header class="section-header">
|
||||||
<i
|
<i
|
||||||
class="mr-2 text-sm md:mr-3 md:text-base"
|
class="header-icon"
|
||||||
:class="
|
:class="
|
||||||
multiKeyMode ? 'fas fa-layer-group text-purple-500' : 'fas fa-info-circle text-blue-500'
|
multiKeyMode
|
||||||
|
? 'fas fa-layer-group text-purple-500'
|
||||||
|
: 'fas fa-info-circle text-blue-500'
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
{{ multiKeyMode ? '批量查询概要' : 'API Key 信息' }}
|
<h3 class="header-title">{{ multiKeyMode ? '批量查询概要' : 'API Key 信息' }}</h3>
|
||||||
</h3>
|
</header>
|
||||||
|
|
||||||
<!-- 多 Key 模式下的概要信息 -->
|
<div v-if="multiKeyMode && aggregatedStats" class="info-grid">
|
||||||
<div v-if="multiKeyMode && aggregatedStats" class="space-y-2 md:space-y-3">
|
<div class="info-item">
|
||||||
<div class="flex items-center justify-between">
|
<p class="info-label">查询 Keys 数</p>
|
||||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">查询 Keys 数</span>
|
<p class="info-value">{{ aggregatedStats.totalKeys }} 个</p>
|
||||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 md:text-base">
|
|
||||||
{{ aggregatedStats.totalKeys }} 个
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between">
|
<div class="info-item">
|
||||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">有效 Keys 数</span>
|
<p class="info-label">有效 Keys 数</p>
|
||||||
<span class="text-sm font-medium text-green-600 md:text-base">
|
<p class="info-value text-green-600 dark:text-emerald-400">
|
||||||
<i class="fas fa-check-circle mr-1 text-xs md:text-sm" />
|
<i class="fas fa-check-circle mr-1" />{{ aggregatedStats.activeKeys }} 个
|
||||||
{{ aggregatedStats.activeKeys }} 个
|
</p>
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-if="invalidKeys.length > 0" class="flex items-center justify-between">
|
<div v-if="invalidKeys.length > 0" class="info-item">
|
||||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">无效 Keys 数</span>
|
<p class="info-label">无效 Keys 数</p>
|
||||||
<span class="text-sm font-medium text-red-600 md:text-base">
|
<p class="info-value text-red-500 dark:text-red-400">
|
||||||
<i class="fas fa-times-circle mr-1 text-xs md:text-sm" />
|
<i class="fas fa-times-circle mr-1" />{{ invalidKeys.length }} 个
|
||||||
{{ invalidKeys.length }} 个
|
</p>
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between">
|
<div class="info-item">
|
||||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">总请求数</span>
|
<p class="info-label">总请求数</p>
|
||||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 md:text-base">
|
<p class="info-value">{{ formatNumber(aggregatedStats.usage.requests) }}</p>
|
||||||
{{ formatNumber(aggregatedStats.usage.requests) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between">
|
<div class="info-item">
|
||||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">总 Token 数</span>
|
<p class="info-label">总 Token 数</p>
|
||||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 md:text-base">
|
<p class="info-value">{{ formatNumber(aggregatedStats.usage.allTokens) }}</p>
|
||||||
{{ formatNumber(aggregatedStats.usage.allTokens) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between">
|
<div class="info-item">
|
||||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">总费用</span>
|
<p class="info-label">总费用</p>
|
||||||
<span class="text-sm font-medium text-indigo-600 md:text-base">
|
<p class="info-value text-indigo-600 dark:text-indigo-300">
|
||||||
{{ aggregatedStats.usage.formattedCost }}
|
{{ aggregatedStats.usage.formattedCost }}
|
||||||
</span>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="individualStats.length > 1" class="info-item xl:col-span-2">
|
||||||
<!-- 各 Key 贡献占比(可选) -->
|
<p class="info-label">Top 3 贡献占比</p>
|
||||||
<div
|
<div class="space-y-2">
|
||||||
v-if="individualStats.length > 1"
|
<div v-for="stat in topContributors" :key="stat.apiId" class="contributor-item">
|
||||||
class="border-t border-gray-200 pt-2 dark:border-gray-700"
|
<span class="truncate">{{ stat.name }}</span>
|
||||||
>
|
<span class="font-semibold">{{ calculateContribution(stat) }}%</span>
|
||||||
<div class="mb-2 text-xs text-gray-500 dark:text-gray-400">各 Key 贡献占比</div>
|
|
||||||
<div class="space-y-1">
|
|
||||||
<div
|
|
||||||
v-for="stat in topContributors"
|
|
||||||
:key="stat.apiId"
|
|
||||||
class="flex items-center justify-between text-xs"
|
|
||||||
>
|
|
||||||
<span class="truncate text-gray-600 dark:text-gray-400">{{ stat.name }}</span>
|
|
||||||
<span class="text-gray-900 dark:text-gray-100"
|
|
||||||
>{{ calculateContribution(stat) }}%</span
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 单 Key 模式下的详细信息 -->
|
<div v-else class="info-grid">
|
||||||
<div v-else class="space-y-2 md:space-y-3">
|
<div class="info-item">
|
||||||
<div class="flex items-center justify-between">
|
<p class="info-label">名称</p>
|
||||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">名称</span>
|
<p class="info-value break-all">{{ statsData.name }}</p>
|
||||||
<span
|
|
||||||
class="break-all text-sm font-medium text-gray-900 dark:text-gray-100 md:text-base"
|
|
||||||
>{{ statsData.name }}</span
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between">
|
<div class="info-item">
|
||||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">状态</span>
|
<p class="info-label">状态</p>
|
||||||
<span
|
<p
|
||||||
class="text-sm font-medium md:text-base"
|
class="info-value font-semibold"
|
||||||
:class="statsData.isActive ? 'text-green-600' : 'text-red-600'"
|
:class="
|
||||||
|
statsData.isActive
|
||||||
|
? 'text-green-600 dark:text-emerald-400'
|
||||||
|
: 'text-red-500 dark:text-red-400'
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<i
|
<i
|
||||||
class="mr-1 text-xs md:text-sm"
|
class="mr-1"
|
||||||
:class="statsData.isActive ? 'fas fa-check-circle' : 'fas fa-times-circle'"
|
:class="statsData.isActive ? 'fas fa-check-circle' : 'fas fa-times-circle'"
|
||||||
/>
|
/>
|
||||||
{{ statsData.isActive ? '活跃' : '已停用' }}
|
{{ statsData.isActive ? '活跃' : '已停用' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<p class="info-label">权限</p>
|
||||||
|
<p class="info-value">{{ formatPermissions(statsData.permissions) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<p class="info-label">创建时间</p>
|
||||||
|
<p class="info-value break-all">{{ formatDate(statsData.createdAt) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="info-item xl:col-span-2">
|
||||||
|
<p class="info-label">过期时间</p>
|
||||||
|
<div class="info-value">
|
||||||
|
<template v-if="statsData.expirationMode === 'activation' && !statsData.isActivated">
|
||||||
|
<span class="text-amber-600 dark:text-amber-400">
|
||||||
|
<i class="fas fa-pause-circle mr-1" />未激活
|
||||||
</span>
|
</span>
|
||||||
</div>
|
<span class="ml-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
<div class="flex items-center justify-between">
|
首次使用后
|
||||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">权限</span>
|
{{ statsData.activationDays || (statsData.activationUnit === 'hours' ? 24 : 30) }}
|
||||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 md:text-base">{{
|
{{ statsData.activationUnit === 'hours' ? '小时' : '天' }}过期
|
||||||
formatPermissions(statsData.permissions)
|
</span>
|
||||||
}}</span>
|
</template>
|
||||||
</div>
|
<template v-else-if="statsData.expiresAt">
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">创建时间</span>
|
|
||||||
<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 dark:text-gray-400 md:text-base"
|
|
||||||
>过期时间</span
|
|
||||||
>
|
|
||||||
<!-- 未激活状态 -->
|
|
||||||
<div
|
|
||||||
v-if="statsData.expirationMode === 'activation' && !statsData.isActivated"
|
|
||||||
class="text-sm font-medium text-amber-600 dark:text-amber-500 md:text-base"
|
|
||||||
>
|
|
||||||
<i class="fas fa-pause-circle mr-1 text-xs md:text-sm" />
|
|
||||||
未激活
|
|
||||||
<span class="ml-1 text-xs text-gray-500 dark:text-gray-400"
|
|
||||||
>(首次使用后{{ statsData.activationDays || 30 }}天过期)</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<!-- 已设置过期时间 -->
|
|
||||||
<div v-else-if="statsData.expiresAt" class="text-right">
|
|
||||||
<div
|
|
||||||
v-if="isApiKeyExpired(statsData.expiresAt)"
|
v-if="isApiKeyExpired(statsData.expiresAt)"
|
||||||
class="text-sm font-medium text-red-600 md:text-base"
|
class="text-red-500 dark:text-red-400"
|
||||||
>
|
>
|
||||||
<i class="fas fa-exclamation-circle mr-1 text-xs md:text-sm" />
|
<i class="fas fa-exclamation-circle mr-1" />已过期
|
||||||
已过期
|
</span>
|
||||||
</div>
|
<span
|
||||||
<div
|
|
||||||
v-else-if="isApiKeyExpiringSoon(statsData.expiresAt)"
|
v-else-if="isApiKeyExpiringSoon(statsData.expiresAt)"
|
||||||
class="break-all text-xs font-medium text-orange-600 md:text-base"
|
class="text-orange-500 dark:text-orange-400"
|
||||||
>
|
>
|
||||||
<i class="fas fa-clock mr-1 text-xs md:text-sm" />
|
<i class="fas fa-clock mr-1" />{{ formatExpireDate(statsData.expiresAt) }}
|
||||||
{{ formatExpireDate(statsData.expiresAt) }}
|
</span>
|
||||||
</div>
|
<span v-else>{{ formatExpireDate(statsData.expiresAt) }}</span>
|
||||||
<div
|
</template>
|
||||||
v-else
|
<template v-else>
|
||||||
class="break-all text-xs font-medium text-gray-900 dark:text-gray-100 md:text-base"
|
<span class="text-gray-400 dark:text-gray-500">
|
||||||
>
|
<i class="fas fa-infinity mr-1" />永不过期
|
||||||
{{ formatExpireDate(statsData.expiresAt) }}
|
</span>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
|
||||||
<!-- 永不过期 -->
|
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 使用统计概览 -->
|
<!-- 使用统计概览 -->
|
||||||
<div class="card p-4 md:p-6">
|
<div class="card-section">
|
||||||
<h3
|
<header class="section-header">
|
||||||
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"
|
<i class="header-icon fas fa-chart-bar text-green-500" />
|
||||||
>
|
<h3 class="header-title">使用统计概览</h3>
|
||||||
<span class="flex items-center">
|
<span class="header-tag">{{ statsPeriod === 'daily' ? '今日' : '本月' }}</span>
|
||||||
<i class="fas fa-chart-bar mr-2 text-sm text-green-500 md:mr-3 md:text-base" />
|
</header>
|
||||||
使用统计概览
|
<div class="metric-grid">
|
||||||
</span>
|
<div class="metric-card">
|
||||||
<span class="text-xs font-normal text-gray-600 dark:text-gray-400 sm:ml-2 md:text-sm"
|
<p class="metric-value text-green-600 dark:text-emerald-300">
|
||||||
>({{ statsPeriod === 'daily' ? '今日' : '本月' }})</span
|
|
||||||
>
|
|
||||||
</h3>
|
|
||||||
<div class="grid grid-cols-2 gap-3 md:gap-4">
|
|
||||||
<div class="stat-card text-center">
|
|
||||||
<div class="text-lg font-bold text-green-600 md:text-3xl">
|
|
||||||
{{ formatNumber(currentPeriodData.requests) }}
|
{{ formatNumber(currentPeriodData.requests) }}
|
||||||
|
</p>
|
||||||
|
<p class="metric-label">{{ statsPeriod === 'daily' ? '今日' : '本月' }}请求数</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-gray-600 dark:text-gray-400 md:text-sm">
|
<div class="metric-card">
|
||||||
{{ statsPeriod === 'daily' ? '今日' : '本月' }}请求数
|
<p class="metric-value text-blue-600 dark:text-sky-300">
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card text-center">
|
|
||||||
<div class="text-lg font-bold text-blue-600 md:text-3xl">
|
|
||||||
{{ formatNumber(currentPeriodData.allTokens) }}
|
{{ formatNumber(currentPeriodData.allTokens) }}
|
||||||
|
</p>
|
||||||
|
<p class="metric-label">{{ statsPeriod === 'daily' ? '今日' : '本月' }}Token 数</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-gray-600 dark:text-gray-400 md:text-sm">
|
<div class="metric-card">
|
||||||
{{ statsPeriod === 'daily' ? '今日' : '本月' }}Token数
|
<p class="metric-value text-purple-600 dark:text-violet-300">
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card text-center">
|
|
||||||
<div class="text-lg font-bold text-purple-600 md:text-3xl">
|
|
||||||
{{ currentPeriodData.formattedCost || '$0.000000' }}
|
{{ currentPeriodData.formattedCost || '$0.000000' }}
|
||||||
|
</p>
|
||||||
|
<p class="metric-label">{{ statsPeriod === 'daily' ? '今日' : '本月' }}费用</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-gray-600 dark:text-gray-400 md:text-sm">
|
<div class="metric-card">
|
||||||
{{ statsPeriod === 'daily' ? '今日' : '本月' }}费用
|
<p class="metric-value text-amber-500 dark:text-amber-300">
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card text-center">
|
|
||||||
<div class="text-lg font-bold text-yellow-600 md:text-3xl">
|
|
||||||
{{ formatNumber(currentPeriodData.inputTokens) }}
|
{{ formatNumber(currentPeriodData.inputTokens) }}
|
||||||
|
</p>
|
||||||
|
<p class="metric-label">{{ statsPeriod === 'daily' ? '今日' : '本月' }}输入 Token</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-gray-600 dark:text-gray-400 md:text-sm">
|
</div>
|
||||||
{{ statsPeriod === 'daily' ? '今日' : '本月' }}输入Token
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 专属账号运行状态,仅在单 key 且存在绑定时显示 -->
|
||||||
|
<div v-if="!multiKeyMode && boundAccountList.length > 0" class="card-section">
|
||||||
|
<header class="section-header">
|
||||||
|
<i class="header-icon fas fa-plug text-indigo-500" />
|
||||||
|
<h3 class="header-title">专属账号运行状态</h3>
|
||||||
|
<span class="header-tag">实时更新</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4" :class="accountGridClass">
|
||||||
|
<div
|
||||||
|
v-for="account in boundAccountList"
|
||||||
|
:key="account.id || account.key"
|
||||||
|
class="account-card"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
class="account-icon"
|
||||||
|
:class="account.platform === 'claude' ? 'icon-claude' : 'icon-openai'"
|
||||||
|
>
|
||||||
|
<i :class="account.platform === 'claude' ? 'fas fa-meteor' : 'fas fa-robot'" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p class="account-name">{{ getAccountLabel(account) }}</p>
|
||||||
|
<p class="account-sub">
|
||||||
|
{{ account.platform === 'claude' ? '会话窗口' : '额度窗口' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="getRateLimitDisplay(account.rateLimitStatus)"
|
||||||
|
:class="['rate-badge', getRateLimitDisplay(account.rateLimitStatus).class]"
|
||||||
|
>
|
||||||
|
<i class="fas fa-tachometer-alt mr-1" />
|
||||||
|
{{ getRateLimitDisplay(account.rateLimitStatus).text }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="account.platform === 'claude'" class="mt-3 space-y-2">
|
||||||
|
<div class="progress-row">
|
||||||
|
<div class="progress-track">
|
||||||
|
<div
|
||||||
|
class="progress-bar"
|
||||||
|
:class="
|
||||||
|
getSessionProgressBarClass(account.sessionWindow?.sessionWindowStatus, account)
|
||||||
|
"
|
||||||
|
:style="{
|
||||||
|
width: `${Math.min(100, Math.max(0, account.sessionWindow?.progress || 0))}%`
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span class="progress-value">
|
||||||
|
{{ Math.min(100, Math.max(0, Math.round(account.sessionWindow?.progress || 0))) }}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-2 text-xs text-gray-600 dark:text-gray-300">
|
||||||
|
<span>
|
||||||
|
{{
|
||||||
|
formatSessionWindowRange(
|
||||||
|
account.sessionWindow?.windowStart,
|
||||||
|
account.sessionWindow?.windowEnd
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="account.sessionWindow?.remainingTime > 0"
|
||||||
|
class="font-medium text-indigo-600 dark:text-indigo-400"
|
||||||
|
>
|
||||||
|
剩余 {{ formatSessionRemaining(account.sessionWindow.remainingTime) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="account.platform === 'openai'" class="mt-3">
|
||||||
|
<div v-if="account.codexUsage" class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="type in ['primary', 'secondary']"
|
||||||
|
:key="`${account.key}-${type}`"
|
||||||
|
class="quota-row"
|
||||||
|
>
|
||||||
|
<div class="quota-header">
|
||||||
|
<span class="quota-tag" :class="type === 'primary' ? 'tag-indigo' : 'tag-blue'">
|
||||||
|
{{ getCodexWindowLabel(type) }}
|
||||||
|
</span>
|
||||||
|
<span class="quota-percent">
|
||||||
|
{{ formatCodexUsagePercent(account.codexUsage?.[type]) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress-track">
|
||||||
|
<div
|
||||||
|
class="progress-bar"
|
||||||
|
:class="getCodexUsageBarClass(account.codexUsage?.[type])"
|
||||||
|
:style="{ width: getCodexUsageWidth(account.codexUsage?.[type]) }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="quota-foot">
|
||||||
|
重置剩余 {{ formatCodexRemaining(account.codexUsage?.[type]) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
v-else
|
||||||
|
class="rounded-xl bg-slate-100 px-3 py-2 text-xs text-slate-500 dark:bg-slate-800 dark:text-slate-300"
|
||||||
|
>
|
||||||
|
暂无额度使用数据
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -213,8 +280,8 @@
|
|||||||
/* eslint-disable no-unused-vars */
|
/* eslint-disable no-unused-vars */
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { useApiStatsStore } from '@/stores/apistats'
|
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
import { useApiStatsStore } from '@/stores/apistats'
|
||||||
|
|
||||||
const apiStatsStore = useApiStatsStore()
|
const apiStatsStore = useApiStatsStore()
|
||||||
const {
|
const {
|
||||||
@@ -227,35 +294,28 @@ const {
|
|||||||
invalidKeys
|
invalidKeys
|
||||||
} = storeToRefs(apiStatsStore)
|
} = storeToRefs(apiStatsStore)
|
||||||
|
|
||||||
// 计算前3个贡献最大的 Key
|
|
||||||
const topContributors = computed(() => {
|
const topContributors = computed(() => {
|
||||||
if (!individualStats.value || individualStats.value.length === 0) return []
|
if (!individualStats.value || individualStats.value.length === 0) return []
|
||||||
|
|
||||||
return [...individualStats.value]
|
return [...individualStats.value]
|
||||||
.sort((a, b) => (b.usage?.allTokens || 0) - (a.usage?.allTokens || 0))
|
.sort((a, b) => (b.usage?.allTokens || 0) - (a.usage?.allTokens || 0))
|
||||||
.slice(0, 3)
|
.slice(0, 3)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 计算单个 Key 的贡献占比
|
|
||||||
const calculateContribution = (stat) => {
|
const calculateContribution = (stat) => {
|
||||||
if (!aggregatedStats.value || !aggregatedStats.value.usage.allTokens) return 0
|
if (!aggregatedStats.value || !aggregatedStats.value.usage.allTokens) return 0
|
||||||
const percentage = ((stat.usage?.allTokens || 0) / aggregatedStats.value.usage.allTokens) * 100
|
const percentage = ((stat.usage?.allTokens || 0) / aggregatedStats.value.usage.allTokens) * 100
|
||||||
return percentage.toFixed(1)
|
return percentage.toFixed(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 格式化日期
|
|
||||||
const formatDate = (dateString) => {
|
const formatDate = (dateString) => {
|
||||||
if (!dateString) return '无'
|
if (!dateString) return '无'
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const date = dayjs(dateString)
|
return dayjs(dateString).format('YYYY年MM月DD日 HH:mm')
|
||||||
return date.format('YYYY年MM月DD日 HH:mm')
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return '格式错误'
|
return '格式错误'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 格式化过期日期
|
|
||||||
const formatExpireDate = (dateString) => {
|
const formatExpireDate = (dateString) => {
|
||||||
if (!dateString) return ''
|
if (!dateString) return ''
|
||||||
const date = new Date(dateString)
|
const date = new Date(dateString)
|
||||||
@@ -268,13 +328,11 @@ const formatExpireDate = (dateString) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查 API Key 是否已过期
|
|
||||||
const isApiKeyExpired = (expiresAt) => {
|
const isApiKeyExpired = (expiresAt) => {
|
||||||
if (!expiresAt) return false
|
if (!expiresAt) return false
|
||||||
return new Date(expiresAt) < new Date()
|
return new Date(expiresAt) < new Date()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查 API Key 是否即将过期(7天内)
|
|
||||||
const isApiKeyExpiringSoon = (expiresAt) => {
|
const isApiKeyExpiringSoon = (expiresAt) => {
|
||||||
if (!expiresAt) return false
|
if (!expiresAt) return false
|
||||||
const expireDate = new Date(expiresAt)
|
const expireDate = new Date(expiresAt)
|
||||||
@@ -283,130 +341,317 @@ const isApiKeyExpiringSoon = (expiresAt) => {
|
|||||||
return daysUntilExpire > 0 && daysUntilExpire <= 7
|
return daysUntilExpire > 0 && daysUntilExpire <= 7
|
||||||
}
|
}
|
||||||
|
|
||||||
// 格式化数字
|
|
||||||
const formatNumber = (num) => {
|
const formatNumber = (num) => {
|
||||||
if (typeof num !== 'number') {
|
if (typeof num !== 'number') num = parseInt(num) || 0
|
||||||
num = parseInt(num) || 0
|
|
||||||
}
|
|
||||||
|
|
||||||
if (num === 0) return '0'
|
if (num === 0) return '0'
|
||||||
|
if (num >= 1_000_000) return (num / 1_000_000).toFixed(1) + 'M'
|
||||||
// 大数字使用简化格式
|
if (num >= 1_000) return (num / 1_000).toFixed(1) + 'K'
|
||||||
if (num >= 1000000) {
|
|
||||||
return (num / 1000000).toFixed(1) + 'M'
|
|
||||||
} else if (num >= 1000) {
|
|
||||||
return (num / 1000).toFixed(1) + 'K'
|
|
||||||
} else {
|
|
||||||
return num.toLocaleString()
|
return num.toLocaleString()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 格式化权限
|
|
||||||
const formatPermissions = (permissions) => {
|
const formatPermissions = (permissions) => {
|
||||||
const permissionMap = {
|
const map = {
|
||||||
claude: 'Claude',
|
claude: 'Claude',
|
||||||
gemini: 'Gemini',
|
gemini: 'Gemini',
|
||||||
all: '全部模型'
|
all: '全部模型'
|
||||||
}
|
}
|
||||||
|
return map[permissions] || permissions || '未知'
|
||||||
return permissionMap[permissions] || permissions || '未知'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const boundAccountList = computed(() => {
|
||||||
|
const accounts = statsData.value?.accounts?.details
|
||||||
|
if (!accounts) return []
|
||||||
|
const result = []
|
||||||
|
if (accounts.claude && accounts.claude.accountType === 'dedicated') {
|
||||||
|
result.push({ key: 'claude', ...accounts.claude })
|
||||||
|
}
|
||||||
|
if (accounts.openai && accounts.openai.accountType === 'dedicated') {
|
||||||
|
result.push({ key: 'openai', ...accounts.openai })
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
const accountGridClass = computed(() => {
|
||||||
|
const count = boundAccountList.value.length
|
||||||
|
if (count <= 1) {
|
||||||
|
return 'md:grid-cols-1 lg:grid-cols-1'
|
||||||
|
}
|
||||||
|
if (count === 2) {
|
||||||
|
return 'md:grid-cols-2'
|
||||||
|
}
|
||||||
|
return 'md:grid-cols-2 xl:grid-cols-3'
|
||||||
|
})
|
||||||
|
|
||||||
|
const getAccountLabel = (account) => {
|
||||||
|
if (!account) return '专属账号'
|
||||||
|
return account.platform === 'openai' ? 'OpenAI 专属账号' : 'Claude 专属账号'
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatRateLimitTime = (minutes) => {
|
||||||
|
if (!minutes || minutes <= 0) return ''
|
||||||
|
const total = Math.floor(minutes)
|
||||||
|
const days = Math.floor(total / 1440)
|
||||||
|
const hours = Math.floor((total % 1440) / 60)
|
||||||
|
const mins = total % 60
|
||||||
|
if (days > 0) return hours > 0 ? `${days}天${hours}小时` : `${days}天`
|
||||||
|
if (hours > 0) return mins > 0 ? `${hours}小时${mins}分钟` : `${hours}小时`
|
||||||
|
return `${mins}分钟`
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRateLimitDisplay = (status) => {
|
||||||
|
if (!status) {
|
||||||
|
return {
|
||||||
|
text: '状态未知',
|
||||||
|
class: 'text-gray-400'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (status.isRateLimited) {
|
||||||
|
const remaining = formatRateLimitTime(status.minutesRemaining)
|
||||||
|
const suffix = remaining ? ` · 剩余约 ${remaining}` : ''
|
||||||
|
return {
|
||||||
|
text: `限流中${suffix}`,
|
||||||
|
class: 'text-red-500 dark:text-red-400'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
text: '未限流',
|
||||||
|
class: 'text-green-600 dark:text-emerald-400'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatSessionWindowRange = (start, end) => {
|
||||||
|
if (!start || !end) return '暂无时间窗口信息'
|
||||||
|
const s = new Date(start)
|
||||||
|
const e = new Date(end)
|
||||||
|
const fmt = (d) => `${`${d.getHours()}`.padStart(2, '0')}:${`${d.getMinutes()}`.padStart(2, '0')}`
|
||||||
|
return `${fmt(s)} - ${fmt(e)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatSessionRemaining = (minutes) => {
|
||||||
|
if (!minutes || minutes <= 0) return ''
|
||||||
|
const hours = Math.floor(minutes / 60)
|
||||||
|
const mins = minutes % 60
|
||||||
|
return hours > 0 ? `${hours}小时${mins}分钟` : `${mins}分钟`
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSessionProgressBarClass = (status, account) => {
|
||||||
|
if (!status) return 'bg-gradient-to-r from-blue-500 to-indigo-500'
|
||||||
|
if (account?.rateLimitStatus?.isRateLimited) return 'bg-gradient-to-r from-red-500 to-red-600'
|
||||||
|
const normalized = String(status).toLowerCase()
|
||||||
|
if (normalized === 'rejected') return 'bg-gradient-to-r from-red-500 to-red-600'
|
||||||
|
if (normalized === 'allowed_warning') return 'bg-gradient-to-r from-yellow-500 to-orange-500'
|
||||||
|
return 'bg-gradient-to-r from-blue-500 to-indigo-500'
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeCodexUsagePercent = (usageItem) => {
|
||||||
|
if (!usageItem) return null
|
||||||
|
const percent =
|
||||||
|
typeof usageItem.usedPercent === 'number' && !Number.isNaN(usageItem.usedPercent)
|
||||||
|
? usageItem.usedPercent
|
||||||
|
: null
|
||||||
|
const resetAfterSeconds =
|
||||||
|
typeof usageItem.resetAfterSeconds === 'number' && !Number.isNaN(usageItem.resetAfterSeconds)
|
||||||
|
? usageItem.resetAfterSeconds
|
||||||
|
: null
|
||||||
|
const remainingSeconds =
|
||||||
|
typeof usageItem.remainingSeconds === 'number' ? usageItem.remainingSeconds : null
|
||||||
|
const resetAtMs = usageItem.resetAt ? Date.parse(usageItem.resetAt) : null
|
||||||
|
const resetElapsed =
|
||||||
|
resetAfterSeconds !== null &&
|
||||||
|
((remainingSeconds !== null && remainingSeconds <= 0) ||
|
||||||
|
(resetAtMs !== null && !Number.isNaN(resetAtMs) && Date.now() >= resetAtMs))
|
||||||
|
if (resetElapsed) return 0
|
||||||
|
if (percent === null) return null
|
||||||
|
return Math.max(0, Math.min(100, percent))
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCodexUsageBarClass = (usageItem) => {
|
||||||
|
const percent = normalizeCodexUsagePercent(usageItem)
|
||||||
|
if (percent === null) return 'bg-gradient-to-r from-gray-300 to-gray-400'
|
||||||
|
if (percent >= 90) return 'bg-gradient-to-r from-red-500 to-red-600'
|
||||||
|
if (percent >= 75) return 'bg-gradient-to-r from-yellow-500 to-orange-500'
|
||||||
|
return 'bg-gradient-to-r from-emerald-500 to-teal-500'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCodexUsageWidth = (usageItem) => {
|
||||||
|
const percent = normalizeCodexUsagePercent(usageItem)
|
||||||
|
if (percent === null) return '0%'
|
||||||
|
return `${percent}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatCodexUsagePercent = (usageItem) => {
|
||||||
|
const percent = normalizeCodexUsagePercent(usageItem)
|
||||||
|
if (percent === null) return '--'
|
||||||
|
return `${percent.toFixed(1)}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatCodexRemaining = (usageItem) => {
|
||||||
|
if (!usageItem) return '--'
|
||||||
|
let seconds = usageItem.remainingSeconds
|
||||||
|
if (seconds === null || seconds === undefined) {
|
||||||
|
seconds = usageItem.resetAfterSeconds
|
||||||
|
}
|
||||||
|
if (seconds === null || seconds === undefined || Number.isNaN(Number(seconds))) {
|
||||||
|
return '--'
|
||||||
|
}
|
||||||
|
seconds = Math.max(0, Math.floor(Number(seconds)))
|
||||||
|
const days = Math.floor(seconds / 86400)
|
||||||
|
const hours = Math.floor((seconds % 86400) / 3600)
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60)
|
||||||
|
const secs = seconds % 60
|
||||||
|
if (days > 0) return hours > 0 ? `${days}天${hours}小时` : `${days}天`
|
||||||
|
if (hours > 0) return minutes > 0 ? `${hours}小时${minutes}分钟` : `${hours}小时`
|
||||||
|
if (minutes > 0) return `${minutes}分钟`
|
||||||
|
return `${secs}秒`
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCodexWindowLabel = (type) => (type === 'secondary' ? '周限' : '5h')
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* 卡片样式 - 使用CSS变量 */
|
.card-section {
|
||||||
.card {
|
@apply flex h-full flex-col gap-4 rounded-2xl border border-slate-200/70 bg-white/90 p-4 shadow-md dark:border-slate-700/60 dark:bg-slate-900/70 md:p-6;
|
||||||
background: var(--surface-color);
|
|
||||||
border-radius: 16px;
|
|
||||||
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: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card::before {
|
:global(.dark) .card-section {
|
||||||
content: '';
|
backdrop-filter: blur(10px);
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
height: 1px;
|
|
||||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.5), transparent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card:hover {
|
.section-header {
|
||||||
transform: translateY(-2px);
|
@apply mb-4 flex items-center gap-3;
|
||||||
box-shadow:
|
|
||||||
0 20px 25px -5px rgba(0, 0, 0, 0.15),
|
|
||||||
0 10px 10px -5px rgba(0, 0, 0, 0.08);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.dark) .card:hover {
|
.header-icon {
|
||||||
box-shadow:
|
@apply text-base md:text-lg;
|
||||||
0 20px 25px -5px rgba(0, 0, 0, 0.5),
|
|
||||||
0 10px 10px -5px rgba(0, 0, 0, 0.35);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 统计卡片样式 - 使用CSS变量 */
|
.header-title {
|
||||||
.stat-card {
|
@apply text-lg font-semibold text-slate-900 dark:text-slate-100 md:text-xl;
|
||||||
background: linear-gradient(135deg, var(--surface-color) 0%, var(--glass-strong-color) 100%);
|
}
|
||||||
border-radius: 16px;
|
|
||||||
border: 1px solid var(--border-color);
|
.header-tag {
|
||||||
padding: 16px;
|
@apply ml-auto rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-500 dark:bg-slate-800 dark:text-slate-300;
|
||||||
position: relative;
|
}
|
||||||
overflow: hidden;
|
|
||||||
transition: all 0.3s ease;
|
.info-grid {
|
||||||
|
@apply grid gap-3 md:gap-4;
|
||||||
|
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
.stat-card {
|
.info-grid {
|
||||||
border-radius: 20px;
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
padding: 24px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card::before {
|
@media (min-width: 1280px) {
|
||||||
content: '';
|
.info-grid {
|
||||||
position: absolute;
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
top: -50%;
|
|
||||||
left: -50%;
|
|
||||||
width: 200%;
|
|
||||||
height: 200%;
|
|
||||||
background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 70%);
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card:hover {
|
|
||||||
transform: translateY(-4px);
|
|
||||||
box-shadow:
|
|
||||||
0 20px 25px -5px rgba(0, 0, 0, 0.1),
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 响应式优化 */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.card {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
.info-item {
|
||||||
.stat-card {
|
@apply rounded-xl border border-slate-200 bg-white/70 p-4 dark:border-slate-700 dark:bg-slate-900/60;
|
||||||
padding: 12px;
|
min-height: 86px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
@apply text-xs uppercase tracking-wide text-slate-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
@apply mt-2 text-sm text-slate-800 dark:text-slate-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contributor-item {
|
||||||
|
@apply flex items-center justify-between rounded-lg bg-slate-50 px-3 py-2 text-xs text-slate-600 dark:bg-slate-800 dark:text-slate-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-grid {
|
||||||
|
@apply grid grid-cols-2 gap-3 md:gap-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card {
|
||||||
|
@apply rounded-xl border border-slate-200 bg-white/70 p-4 text-center shadow-sm dark:border-slate-700 dark:bg-slate-900/60;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-value {
|
||||||
|
@apply text-xl font-semibold md:text-2xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-label {
|
||||||
|
@apply mt-1 text-xs text-slate-500 dark:text-slate-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-card {
|
||||||
|
@apply rounded-2xl border border-slate-200 bg-white/80 p-4 shadow-sm transition-shadow hover:shadow-md dark:border-slate-700 dark:bg-slate-900/60;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-icon {
|
||||||
|
@apply inline-flex h-10 w-10 items-center justify-center rounded-full text-white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-claude {
|
||||||
|
@apply bg-gradient-to-br from-purple-500 to-purple-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-openai {
|
||||||
|
@apply bg-gradient-to-br from-sky-500 to-indigo-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-name {
|
||||||
|
@apply text-sm font-semibold text-slate-900 dark:text-slate-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-sub {
|
||||||
|
@apply text-xs text-slate-500 dark:text-slate-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rate-badge {
|
||||||
|
@apply rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium dark:bg-slate-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-row {
|
||||||
|
@apply flex items-center gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-track {
|
||||||
|
@apply h-1.5 flex-1 rounded-full bg-slate-200 dark:bg-slate-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
@apply h-1.5 rounded-full transition-all duration-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-value {
|
||||||
|
@apply text-xs font-semibold text-slate-600 dark:text-slate-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-row {
|
||||||
|
@apply rounded-xl border border-slate-200 bg-white/60 p-3 dark:border-slate-700 dark:bg-slate-900/50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-header {
|
||||||
|
@apply mb-2 flex items-center justify-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-tag {
|
||||||
|
@apply inline-flex min-w-[34px] justify-center rounded-full px-2 py-0.5 text-[11px] font-semibold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-indigo {
|
||||||
|
@apply bg-indigo-100 text-indigo-600 dark:bg-indigo-500/20 dark:text-indigo-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-blue {
|
||||||
|
@apply bg-sky-100 text-sky-600 dark:bg-sky-500/20 dark:text-sky-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-percent {
|
||||||
|
@apply text-xs font-semibold text-slate-600 dark:text-slate-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-foot {
|
||||||
|
@apply mt-1 text-[11px] text-slate-400 dark:text-slate-300;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -95,6 +95,9 @@ const formatNumber = (num) => {
|
|||||||
box-shadow:
|
box-shadow:
|
||||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|||||||
@@ -111,6 +111,28 @@
|
|||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 选择/取消选择按钮 -->
|
||||||
|
<button
|
||||||
|
class="flex items-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:bg-gray-50 hover:shadow-md dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||||
|
@click="toggleSelectionMode"
|
||||||
|
>
|
||||||
|
<i :class="showCheckboxes ? 'fas fa-times' : 'fas fa-check-square'"></i>
|
||||||
|
<span>{{ showCheckboxes ? '取消选择' : '选择' }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- 批量删除按钮 -->
|
||||||
|
<button
|
||||||
|
v-if="selectedAccounts.length > 0"
|
||||||
|
class="group relative flex items-center justify-center gap-2 rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm font-medium text-red-700 shadow-sm transition-all duration-200 hover:border-red-300 hover:bg-red-100 hover:shadow-md dark:border-red-700 dark:bg-red-900/30 dark:text-red-300 dark:hover:bg-red-900/50 sm:w-auto"
|
||||||
|
@click="batchDeleteAccounts"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="absolute -inset-0.5 rounded-lg bg-gradient-to-r from-red-500 to-pink-500 opacity-0 blur transition duration-300 group-hover:opacity-20"
|
||||||
|
></div>
|
||||||
|
<i class="fas fa-trash relative text-red-600 dark:text-red-400" />
|
||||||
|
<span class="relative">删除选中 ({{ selectedAccounts.length }})</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<!-- 添加账户按钮 -->
|
<!-- 添加账户按钮 -->
|
||||||
<button
|
<button
|
||||||
class="flex w-full items-center justify-center gap-2 rounded-lg bg-gradient-to-r from-green-500 to-green-600 px-5 py-2.5 text-sm font-medium text-white shadow-md transition-all duration-200 hover:from-green-600 hover:to-green-700 hover:shadow-lg sm:w-auto"
|
class="flex w-full items-center justify-center gap-2 rounded-lg bg-gradient-to-r from-green-500 to-green-600 px-5 py-2.5 text-sm font-medium text-white shadow-md transition-all duration-200 hover:from-green-600 hover:to-green-700 hover:shadow-lg sm:w-auto"
|
||||||
@@ -143,6 +165,17 @@
|
|||||||
<table class="w-full table-fixed">
|
<table class="w-full table-fixed">
|
||||||
<thead class="bg-gray-50/80 backdrop-blur-sm dark:bg-gray-700/80">
|
<thead class="bg-gray-50/80 backdrop-blur-sm dark:bg-gray-700/80">
|
||||||
<tr>
|
<tr>
|
||||||
|
<th v-if="shouldShowCheckboxes" class="w-[50px] px-3 py-4 text-left">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input
|
||||||
|
v-model="selectAllChecked"
|
||||||
|
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
|
:indeterminate="isIndeterminate"
|
||||||
|
type="checkbox"
|
||||||
|
@change="handleSelectAll"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
<th
|
<th
|
||||||
class="w-[22%] min-w-[180px] cursor-pointer px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600"
|
class="w-[22%] min-w-[180px] cursor-pointer px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||||
@click="sortAccounts('name')"
|
@click="sortAccounts('name')"
|
||||||
@@ -310,6 +343,17 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-200/50 dark:divide-gray-600/50">
|
<tbody class="divide-y divide-gray-200/50 dark:divide-gray-600/50">
|
||||||
<tr v-for="account in paginatedAccounts" :key="account.id" class="table-row">
|
<tr v-for="account in paginatedAccounts" :key="account.id" class="table-row">
|
||||||
|
<td v-if="shouldShowCheckboxes" class="px-3 py-3">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input
|
||||||
|
v-model="selectedAccounts"
|
||||||
|
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
|
type="checkbox"
|
||||||
|
:value="account.id"
|
||||||
|
@change="updateSelectAllState"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
<td class="px-3 py-4">
|
<td class="px-3 py-4">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div
|
<div
|
||||||
@@ -891,6 +935,14 @@
|
|||||||
<!-- 卡片头部 -->
|
<!-- 卡片头部 -->
|
||||||
<div class="mb-3 flex items-start justify-between">
|
<div class="mb-3 flex items-start justify-between">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
v-if="shouldShowCheckboxes"
|
||||||
|
v-model="selectedAccounts"
|
||||||
|
class="mt-1 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
|
type="checkbox"
|
||||||
|
:value="account.id"
|
||||||
|
@change="updateSelectAllState"
|
||||||
|
/>
|
||||||
<div
|
<div
|
||||||
:class="[
|
:class="[
|
||||||
'flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg',
|
'flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg',
|
||||||
@@ -1371,6 +1423,12 @@ const pageSizeOptions = [10, 20, 50, 100]
|
|||||||
const pageSize = ref(getInitialPageSize())
|
const pageSize = ref(getInitialPageSize())
|
||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
|
|
||||||
|
// 多选状态
|
||||||
|
const selectedAccounts = ref([])
|
||||||
|
const selectAllChecked = ref(false)
|
||||||
|
const isIndeterminate = ref(false)
|
||||||
|
const showCheckboxes = ref(false)
|
||||||
|
|
||||||
// 账号使用详情弹窗状态
|
// 账号使用详情弹窗状态
|
||||||
const showAccountUsageModal = ref(false)
|
const showAccountUsageModal = ref(false)
|
||||||
const accountUsageLoading = ref(false)
|
const accountUsageLoading = ref(false)
|
||||||
@@ -1429,6 +1487,8 @@ const groupOptions = computed(() => {
|
|||||||
return options
|
return options
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const shouldShowCheckboxes = computed(() => showCheckboxes.value)
|
||||||
|
|
||||||
// 模态框状态
|
// 模态框状态
|
||||||
const showCreateAccountModal = ref(false)
|
const showCreateAccountModal = ref(false)
|
||||||
const newAccountPlatform = ref(null) // 跟踪新建账户选择的平台
|
const newAccountPlatform = ref(null) // 跟踪新建账户选择的平台
|
||||||
@@ -1526,7 +1586,6 @@ const openAccountUsageModal = async (account) => {
|
|||||||
showToast(response.error || '加载账号使用详情失败', 'error')
|
showToast(response.error || '加载账号使用详情失败', 'error')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载账号使用详情失败:', error)
|
|
||||||
showToast('加载账号使用详情失败', 'error')
|
showToast('加载账号使用详情失败', 'error')
|
||||||
} finally {
|
} finally {
|
||||||
accountUsageLoading.value = false
|
accountUsageLoading.value = false
|
||||||
@@ -1651,28 +1710,66 @@ const paginatedAccounts = computed(() => {
|
|||||||
return sortedAccounts.value.slice(start, end)
|
return sortedAccounts.value.slice(start, end)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const updateSelectAllState = () => {
|
||||||
|
const currentIds = paginatedAccounts.value.map((account) => account.id)
|
||||||
|
const selectedInCurrentPage = currentIds.filter((id) =>
|
||||||
|
selectedAccounts.value.includes(id)
|
||||||
|
).length
|
||||||
|
const totalInCurrentPage = currentIds.length
|
||||||
|
|
||||||
|
if (selectedInCurrentPage === 0) {
|
||||||
|
selectAllChecked.value = false
|
||||||
|
isIndeterminate.value = false
|
||||||
|
} else if (selectedInCurrentPage === totalInCurrentPage) {
|
||||||
|
selectAllChecked.value = true
|
||||||
|
isIndeterminate.value = false
|
||||||
|
} else {
|
||||||
|
selectAllChecked.value = false
|
||||||
|
isIndeterminate.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectAll = () => {
|
||||||
|
if (selectAllChecked.value) {
|
||||||
|
paginatedAccounts.value.forEach((account) => {
|
||||||
|
if (!selectedAccounts.value.includes(account.id)) {
|
||||||
|
selectedAccounts.value.push(account.id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const currentIds = new Set(paginatedAccounts.value.map((account) => account.id))
|
||||||
|
selectedAccounts.value = selectedAccounts.value.filter((id) => !currentIds.has(id))
|
||||||
|
}
|
||||||
|
updateSelectAllState()
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleSelectionMode = () => {
|
||||||
|
showCheckboxes.value = !showCheckboxes.value
|
||||||
|
if (!showCheckboxes.value) {
|
||||||
|
selectedAccounts.value = []
|
||||||
|
selectAllChecked.value = false
|
||||||
|
isIndeterminate.value = false
|
||||||
|
} else {
|
||||||
|
updateSelectAllState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanupSelectedAccounts = () => {
|
||||||
|
const validIds = new Set(accounts.value.map((account) => account.id))
|
||||||
|
selectedAccounts.value = selectedAccounts.value.filter((id) => validIds.has(id))
|
||||||
|
updateSelectAllState()
|
||||||
|
}
|
||||||
|
|
||||||
// 加载账户列表
|
// 加载账户列表
|
||||||
const loadAccounts = async (forceReload = false) => {
|
const loadAccounts = async (forceReload = false) => {
|
||||||
accountsLoading.value = true
|
accountsLoading.value = true
|
||||||
try {
|
try {
|
||||||
// 检查是否选择了特定分组
|
|
||||||
if (groupFilter.value && groupFilter.value !== 'all' && groupFilter.value !== 'ungrouped') {
|
|
||||||
// 直接调用分组成员接口
|
|
||||||
const response = await apiClient.get(`/admin/account-groups/${groupFilter.value}/members`)
|
|
||||||
if (response.success) {
|
|
||||||
// 分组成员接口已经包含了完整的账户信息,直接使用
|
|
||||||
accounts.value = response.data
|
|
||||||
accountsLoading.value = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 构建查询参数(用于其他筛选情况)
|
// 构建查询参数(用于其他筛选情况)
|
||||||
const params = {}
|
const params = {}
|
||||||
if (platformFilter.value !== 'all') {
|
if (platformFilter.value !== 'all') {
|
||||||
params.platform = platformFilter.value
|
params.platform = platformFilter.value
|
||||||
}
|
}
|
||||||
if (groupFilter.value === 'ungrouped') {
|
if (groupFilter.value !== 'all') {
|
||||||
params.groupId = groupFilter.value
|
params.groupId = groupFilter.value
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1926,6 +2023,7 @@ const loadAccounts = async (forceReload = false) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
accounts.value = filteredAccounts
|
accounts.value = filteredAccounts
|
||||||
|
cleanupSelectedAccounts()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast('加载账户失败', 'error')
|
showToast('加载账户失败', 'error')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -2133,21 +2231,67 @@ const editAccount = (account) => {
|
|||||||
showEditAccountModal.value = true
|
showEditAccountModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getBoundApiKeysForAccount = (account) => {
|
||||||
|
if (!account || !account.id) return []
|
||||||
|
return apiKeys.value.filter((key) => {
|
||||||
|
const accountId = account.id
|
||||||
|
return (
|
||||||
|
key.claudeAccountId === accountId ||
|
||||||
|
key.claudeConsoleAccountId === accountId ||
|
||||||
|
key.geminiAccountId === accountId ||
|
||||||
|
key.openaiAccountId === accountId ||
|
||||||
|
key.azureOpenaiAccountId === accountId ||
|
||||||
|
key.openaiAccountId === `responses:${accountId}`
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveAccountDeleteEndpoint = (account) => {
|
||||||
|
switch (account.platform) {
|
||||||
|
case 'claude':
|
||||||
|
return `/admin/claude-accounts/${account.id}`
|
||||||
|
case 'claude-console':
|
||||||
|
return `/admin/claude-console-accounts/${account.id}`
|
||||||
|
case 'bedrock':
|
||||||
|
return `/admin/bedrock-accounts/${account.id}`
|
||||||
|
case 'openai':
|
||||||
|
return `/admin/openai-accounts/${account.id}`
|
||||||
|
case 'azure_openai':
|
||||||
|
return `/admin/azure-openai-accounts/${account.id}`
|
||||||
|
case 'openai-responses':
|
||||||
|
return `/admin/openai-responses-accounts/${account.id}`
|
||||||
|
case 'ccr':
|
||||||
|
return `/admin/ccr-accounts/${account.id}`
|
||||||
|
case 'gemini':
|
||||||
|
return `/admin/gemini-accounts/${account.id}`
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const performAccountDeletion = async (account) => {
|
||||||
|
const endpoint = resolveAccountDeleteEndpoint(account)
|
||||||
|
if (!endpoint) {
|
||||||
|
return { success: false, message: '不支持的账户类型' }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await apiClient.delete(endpoint)
|
||||||
|
if (data.success) {
|
||||||
|
return { success: true, data }
|
||||||
|
}
|
||||||
|
return { success: false, message: data.message || '删除失败' }
|
||||||
|
} catch (error) {
|
||||||
|
const message = error.response?.data?.message || error.message || '删除失败'
|
||||||
|
return { success: false, message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 删除账户
|
// 删除账户
|
||||||
const deleteAccount = async (account) => {
|
const deleteAccount = async (account) => {
|
||||||
// 检查是否有API Key绑定到此账号
|
const boundKeys = getBoundApiKeysForAccount(account)
|
||||||
const boundKeys = apiKeys.value.filter(
|
|
||||||
(key) =>
|
|
||||||
key.claudeAccountId === account.id ||
|
|
||||||
key.claudeConsoleAccountId === account.id ||
|
|
||||||
key.geminiAccountId === account.id ||
|
|
||||||
key.openaiAccountId === account.id ||
|
|
||||||
key.azureOpenaiAccountId === account.id ||
|
|
||||||
key.openaiAccountId === `responses:${account.id}`
|
|
||||||
)
|
|
||||||
const boundKeysCount = boundKeys.length
|
const boundKeysCount = boundKeys.length
|
||||||
|
|
||||||
// 构建确认消息
|
|
||||||
let confirmMessage = `确定要删除账户 "${account.name}" 吗?`
|
let confirmMessage = `确定要删除账户 "${account.name}" 吗?`
|
||||||
if (boundKeysCount > 0) {
|
if (boundKeysCount > 0) {
|
||||||
confirmMessage += `\n\n⚠️ 注意:此账号有 ${boundKeysCount} 个 API Key 绑定。`
|
confirmMessage += `\n\n⚠️ 注意:此账号有 ${boundKeysCount} 个 API Key 绑定。`
|
||||||
@@ -2159,47 +2303,110 @@ const deleteAccount = async (account) => {
|
|||||||
|
|
||||||
if (!confirmed) return
|
if (!confirmed) return
|
||||||
|
|
||||||
try {
|
const result = await performAccountDeletion(account)
|
||||||
let endpoint
|
|
||||||
if (account.platform === 'claude') {
|
|
||||||
endpoint = `/admin/claude-accounts/${account.id}`
|
|
||||||
} else if (account.platform === 'claude-console') {
|
|
||||||
endpoint = `/admin/claude-console-accounts/${account.id}`
|
|
||||||
} else if (account.platform === 'bedrock') {
|
|
||||||
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 if (account.platform === 'openai-responses') {
|
|
||||||
endpoint = `/admin/openai-responses-accounts/${account.id}`
|
|
||||||
} else if (account.platform === 'ccr') {
|
|
||||||
endpoint = `/admin/ccr-accounts/${account.id}`
|
|
||||||
} else {
|
|
||||||
endpoint = `/admin/gemini-accounts/${account.id}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await apiClient.delete(endpoint)
|
if (result.success) {
|
||||||
|
const data = result.data
|
||||||
if (data.success) {
|
|
||||||
// 根据解绑结果显示不同的消息
|
|
||||||
let toastMessage = '账户已成功删除'
|
let toastMessage = '账户已成功删除'
|
||||||
if (data.unboundKeys > 0) {
|
if (data?.unboundKeys > 0) {
|
||||||
toastMessage += `,${data.unboundKeys} 个 API Key 已切换为共享池模式`
|
toastMessage += `,${data.unboundKeys} 个 API Key 已切换为共享池模式`
|
||||||
}
|
}
|
||||||
showToast(toastMessage, 'success')
|
showToast(toastMessage, 'success')
|
||||||
|
|
||||||
// 清空相关缓存
|
selectedAccounts.value = selectedAccounts.value.filter((id) => id !== account.id)
|
||||||
|
updateSelectAllState()
|
||||||
|
|
||||||
groupMembersLoaded.value = false
|
groupMembersLoaded.value = false
|
||||||
apiKeysLoaded.value = false // 重新加载API Keys以反映解绑变化
|
apiKeysLoaded.value = false
|
||||||
loadAccounts()
|
loadAccounts()
|
||||||
loadApiKeys(true) // 强制重新加载API Keys
|
loadApiKeys(true)
|
||||||
} else {
|
} else {
|
||||||
showToast(data.message || '删除失败', 'error')
|
showToast(result.message || '删除失败', 'error')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
}
|
||||||
showToast('删除失败', 'error')
|
|
||||||
|
// 批量删除账户
|
||||||
|
const batchDeleteAccounts = async () => {
|
||||||
|
if (selectedAccounts.value.length === 0) {
|
||||||
|
showToast('请先选择要删除的账户', 'warning')
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const accountsMap = new Map(accounts.value.map((item) => [item.id, item]))
|
||||||
|
const targets = selectedAccounts.value
|
||||||
|
.map((id) => accountsMap.get(id))
|
||||||
|
.filter((account) => !!account)
|
||||||
|
|
||||||
|
if (targets.length === 0) {
|
||||||
|
showToast('选中的账户已不存在', 'warning')
|
||||||
|
selectedAccounts.value = []
|
||||||
|
updateSelectAllState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let confirmMessage = `确定要删除选中的 ${targets.length} 个账户吗?此操作不可恢复。`
|
||||||
|
const boundInfo = targets
|
||||||
|
.map((account) => ({ account, boundKeys: getBoundApiKeysForAccount(account) }))
|
||||||
|
.filter((item) => item.boundKeys.length > 0)
|
||||||
|
|
||||||
|
if (boundInfo.length > 0) {
|
||||||
|
confirmMessage += '\n\n⚠️ 以下账户存在绑定的 API Key,将自动解绑:'
|
||||||
|
boundInfo.forEach(({ account, boundKeys }) => {
|
||||||
|
const displayName = account.name || account.email || account.accountName || account.id
|
||||||
|
confirmMessage += `\n- ${displayName}: ${boundKeys.length} 个`
|
||||||
|
})
|
||||||
|
confirmMessage += '\n删除后,这些 API Key 将切换为共享池模式。'
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmMessage += '\n\n请再次确认是否继续。'
|
||||||
|
|
||||||
|
const confirmed = await showConfirm('批量删除账户', confirmMessage, '删除', '取消')
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
let successCount = 0
|
||||||
|
let failedCount = 0
|
||||||
|
let totalUnboundKeys = 0
|
||||||
|
const failedDetails = []
|
||||||
|
|
||||||
|
for (const account of targets) {
|
||||||
|
const result = await performAccountDeletion(account)
|
||||||
|
if (result.success) {
|
||||||
|
successCount += 1
|
||||||
|
totalUnboundKeys += result.data?.unboundKeys || 0
|
||||||
|
} else {
|
||||||
|
failedCount += 1
|
||||||
|
failedDetails.push({
|
||||||
|
name: account.name || account.email || account.accountName || account.id,
|
||||||
|
message: result.message || '删除失败'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (successCount > 0) {
|
||||||
|
let toastMessage = `成功删除 ${successCount} 个账户`
|
||||||
|
if (totalUnboundKeys > 0) {
|
||||||
|
toastMessage += `,${totalUnboundKeys} 个 API Key 已切换为共享池模式`
|
||||||
|
}
|
||||||
|
showToast(toastMessage, failedCount > 0 ? 'warning' : 'success')
|
||||||
|
|
||||||
|
selectedAccounts.value = []
|
||||||
|
selectAllChecked.value = false
|
||||||
|
isIndeterminate.value = false
|
||||||
|
|
||||||
|
groupMembersLoaded.value = false
|
||||||
|
apiKeysLoaded.value = false
|
||||||
|
await loadAccounts(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failedCount > 0) {
|
||||||
|
const detailMessage = failedDetails.map((item) => `${item.name}: ${item.message}`).join('\n')
|
||||||
|
showToast(
|
||||||
|
`有 ${failedCount} 个账户删除失败:\n${detailMessage}`,
|
||||||
|
successCount > 0 ? 'warning' : 'error'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSelectAllState()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重置账户状态
|
// 重置账户状态
|
||||||
@@ -2759,10 +2966,12 @@ const calculateDailyCost = (account) => {
|
|||||||
|
|
||||||
watch(searchKeyword, () => {
|
watch(searchKeyword, () => {
|
||||||
currentPage.value = 1
|
currentPage.value = 1
|
||||||
|
updateSelectAllState()
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(pageSize, (newSize) => {
|
watch(pageSize, (newSize) => {
|
||||||
localStorage.setItem(PAGE_SIZE_STORAGE_KEY, newSize.toString())
|
localStorage.setItem(PAGE_SIZE_STORAGE_KEY, newSize.toString())
|
||||||
|
updateSelectAllState()
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -2771,6 +2980,7 @@ watch(
|
|||||||
if (currentPage.value > totalPages.value) {
|
if (currentPage.value > totalPages.value) {
|
||||||
currentPage.value = totalPages.value || 1
|
currentPage.value = totalPages.value || 1
|
||||||
}
|
}
|
||||||
|
updateSelectAllState()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2789,6 +2999,18 @@ watch(accountSortBy, (newVal) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(currentPage, () => {
|
||||||
|
updateSelectAllState()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(paginatedAccounts, () => {
|
||||||
|
updateSelectAllState()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(accounts, () => {
|
||||||
|
cleanupSelectedAccounts()
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// 首次加载时强制刷新所有数据
|
// 首次加载时强制刷新所有数据
|
||||||
loadAccounts(true)
|
loadAccounts(true)
|
||||||
|
|||||||
@@ -660,7 +660,9 @@
|
|||||||
style="font-size: 13px"
|
style="font-size: 13px"
|
||||||
>
|
>
|
||||||
<i class="fas fa-pause-circle mr-1 text-xs" />
|
<i class="fas fa-pause-circle mr-1 text-xs" />
|
||||||
未激活 ({{ key.activationDays || 30 }}天)
|
未激活 (
|
||||||
|
{{ key.activationDays || (key.activationUnit === 'hours' ? 24 : 30)
|
||||||
|
}}{{ key.activationUnit === 'hours' ? '小时' : '天' }})
|
||||||
</span>
|
</span>
|
||||||
<!-- 已设置过期时间 -->
|
<!-- 已设置过期时间 -->
|
||||||
<span v-else-if="key.expiresAt">
|
<span v-else-if="key.expiresAt">
|
||||||
@@ -3523,7 +3525,86 @@ const exportToExcel = () => {
|
|||||||
|
|
||||||
// 基础数据
|
// 基础数据
|
||||||
const baseData = {
|
const baseData = {
|
||||||
|
ID: key.id || '',
|
||||||
名称: key.name || '',
|
名称: key.name || '',
|
||||||
|
描述: key.description || '',
|
||||||
|
状态: key.isActive ? '启用' : '禁用',
|
||||||
|
API密钥: key.apiKey || '',
|
||||||
|
|
||||||
|
// 过期配置
|
||||||
|
过期模式:
|
||||||
|
key.expirationMode === 'activation'
|
||||||
|
? '首次使用后激活'
|
||||||
|
: key.expirationMode === 'fixed'
|
||||||
|
? '固定时间'
|
||||||
|
: '无',
|
||||||
|
激活期限: key.activationDays || '',
|
||||||
|
激活单位:
|
||||||
|
key.activationUnit === 'hours' ? '小时' : key.activationUnit === 'days' ? '天' : '',
|
||||||
|
已激活: key.isActivated ? '是' : '否',
|
||||||
|
激活时间: key.activatedAt ? formatDate(key.activatedAt) : '',
|
||||||
|
过期时间: key.expiresAt ? formatDate(key.expiresAt) : '',
|
||||||
|
|
||||||
|
// 权限配置
|
||||||
|
服务权限:
|
||||||
|
key.permissions === 'all'
|
||||||
|
? '全部服务'
|
||||||
|
: key.permissions === 'claude'
|
||||||
|
? '仅Claude'
|
||||||
|
: key.permissions === 'gemini'
|
||||||
|
? '仅Gemini'
|
||||||
|
: key.permissions === 'openai'
|
||||||
|
? '仅OpenAI'
|
||||||
|
: key.permissions || '',
|
||||||
|
|
||||||
|
// 限制配置
|
||||||
|
令牌限制: key.tokenLimit === '0' || key.tokenLimit === 0 ? '无限制' : key.tokenLimit || '',
|
||||||
|
并发限制:
|
||||||
|
key.concurrencyLimit === '0' || key.concurrencyLimit === 0
|
||||||
|
? '无限制'
|
||||||
|
: key.concurrencyLimit || '',
|
||||||
|
'速率窗口(分钟)':
|
||||||
|
key.rateLimitWindow === '0' || key.rateLimitWindow === 0
|
||||||
|
? '无限制'
|
||||||
|
: key.rateLimitWindow || '',
|
||||||
|
速率请求限制:
|
||||||
|
key.rateLimitRequests === '0' || key.rateLimitRequests === 0
|
||||||
|
? '无限制'
|
||||||
|
: key.rateLimitRequests || '',
|
||||||
|
'日费用限制($)':
|
||||||
|
key.dailyCostLimit === '0' || key.dailyCostLimit === 0
|
||||||
|
? '无限制'
|
||||||
|
: `$${key.dailyCostLimit}` || '',
|
||||||
|
'总费用限制($)':
|
||||||
|
key.totalCostLimit === '0' || key.totalCostLimit === 0
|
||||||
|
? '无限制'
|
||||||
|
: `$${key.totalCostLimit}` || '',
|
||||||
|
|
||||||
|
// 账户绑定
|
||||||
|
Claude专属账户: key.claudeAccountId || '',
|
||||||
|
Claude控制台账户: key.claudeConsoleAccountId || '',
|
||||||
|
Gemini专属账户: key.geminiAccountId || '',
|
||||||
|
OpenAI专属账户: key.openaiAccountId || '',
|
||||||
|
'Azure OpenAI专属账户': key.azureOpenaiAccountId || '',
|
||||||
|
Bedrock专属账户: key.bedrockAccountId || '',
|
||||||
|
|
||||||
|
// 模型和客户端限制
|
||||||
|
启用模型限制: key.enableModelRestriction ? '是' : '否',
|
||||||
|
限制的模型:
|
||||||
|
key.restrictedModels && key.restrictedModels.length > 0
|
||||||
|
? key.restrictedModels.join('; ')
|
||||||
|
: '',
|
||||||
|
启用客户端限制: key.enableClientRestriction ? '是' : '否',
|
||||||
|
允许的客户端:
|
||||||
|
key.allowedClients && key.allowedClients.length > 0 ? key.allowedClients.join('; ') : '',
|
||||||
|
|
||||||
|
// 创建信息
|
||||||
|
创建时间: key.createdAt ? formatDate(key.createdAt) : '',
|
||||||
|
创建者: key.createdBy || '',
|
||||||
|
用户ID: key.userId || '',
|
||||||
|
用户名: key.userUsername || '',
|
||||||
|
|
||||||
|
// 使用统计
|
||||||
标签: key.tags && key.tags.length > 0 ? key.tags.join(', ') : '无',
|
标签: key.tags && key.tags.length > 0 ? key.tags.join(', ') : '无',
|
||||||
请求总数: periodRequests,
|
请求总数: periodRequests,
|
||||||
'总费用($)': periodCost.toFixed(2),
|
'总费用($)': periodCost.toFixed(2),
|
||||||
@@ -3580,12 +3661,33 @@ const exportToExcel = () => {
|
|||||||
// 设置列宽
|
// 设置列宽
|
||||||
const headers = Object.keys(exportData[0] || {})
|
const headers = Object.keys(exportData[0] || {})
|
||||||
const columnWidths = headers.map((header) => {
|
const columnWidths = headers.map((header) => {
|
||||||
|
// 基本信息字段
|
||||||
|
if (header === 'ID') return { wch: 40 }
|
||||||
if (header === '名称') return { wch: 25 }
|
if (header === '名称') return { wch: 25 }
|
||||||
|
if (header === '描述') return { wch: 30 }
|
||||||
|
if (header === 'API密钥') return { wch: 45 }
|
||||||
if (header === '标签') return { wch: 20 }
|
if (header === '标签') return { wch: 20 }
|
||||||
if (header === '最后使用时间') return { wch: 20 }
|
|
||||||
|
// 时间字段
|
||||||
|
if (header.includes('时间')) return { wch: 20 }
|
||||||
|
|
||||||
|
// 限制字段
|
||||||
|
if (header.includes('限制')) return { wch: 15 }
|
||||||
if (header.includes('费用')) return { wch: 15 }
|
if (header.includes('费用')) return { wch: 15 }
|
||||||
if (header.includes('Token')) return { wch: 15 }
|
if (header.includes('Token')) return { wch: 15 }
|
||||||
if (header.includes('请求')) return { wch: 12 }
|
if (header.includes('请求')) return { wch: 12 }
|
||||||
|
|
||||||
|
// 账户绑定字段
|
||||||
|
if (header.includes('账户')) return { wch: 30 }
|
||||||
|
|
||||||
|
// 权限配置字段
|
||||||
|
if (header.includes('权限') || header.includes('模型') || header.includes('客户端'))
|
||||||
|
return { wch: 20 }
|
||||||
|
|
||||||
|
// 激活配置字段
|
||||||
|
if (header.includes('激活') || header.includes('过期')) return { wch: 18 }
|
||||||
|
|
||||||
|
// 默认宽度
|
||||||
return { wch: 15 }
|
return { wch: 15 }
|
||||||
})
|
})
|
||||||
ws['!cols'] = columnWidths
|
ws['!cols'] = columnWidths
|
||||||
|
|||||||
@@ -123,12 +123,16 @@
|
|||||||
<StatsOverview />
|
<StatsOverview />
|
||||||
|
|
||||||
<!-- Token 分布和限制配置 -->
|
<!-- Token 分布和限制配置 -->
|
||||||
<div class="mb-6 grid grid-cols-1 gap-4 md:mb-8 md:gap-6 lg:grid-cols-2">
|
<div
|
||||||
<TokenDistribution />
|
class="mb-6 mt-6 grid grid-cols-1 gap-4 md:mb-8 md:mt-8 md:gap-6 xl:grid-cols-2 xl:items-stretch"
|
||||||
<!-- 单key模式下显示限制配置 -->
|
>
|
||||||
<LimitConfig v-if="!multiKeyMode" />
|
<TokenDistribution class="h-full" />
|
||||||
<!-- 多key模式下显示聚合统计卡片,填充右侧空白 -->
|
<template v-if="multiKeyMode">
|
||||||
<AggregatedStatsCard v-if="multiKeyMode" />
|
<AggregatedStatsCard class="h-full" />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<LimitConfig class="h-full" />
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 模型使用统计 -->
|
<!-- 模型使用统计 -->
|
||||||
|
|||||||
@@ -425,7 +425,7 @@
|
|||||||
<p class="mb-3 text-sm text-yellow-700">
|
<p class="mb-3 text-sm text-yellow-700">
|
||||||
在
|
在
|
||||||
<code class="rounded bg-yellow-100 px-1">~/.codex/config.toml</code>
|
<code class="rounded bg-yellow-100 px-1">~/.codex/config.toml</code>
|
||||||
文件中添加以下配置:
|
文件开头添加以下配置:
|
||||||
</p>
|
</p>
|
||||||
<div
|
<div
|
||||||
class="overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
|
class="overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
|
||||||
@@ -956,7 +956,7 @@
|
|||||||
<p class="mb-3 text-sm text-yellow-700">
|
<p class="mb-3 text-sm text-yellow-700">
|
||||||
在
|
在
|
||||||
<code class="rounded bg-yellow-100 px-1">~/.codex/config.toml</code>
|
<code class="rounded bg-yellow-100 px-1">~/.codex/config.toml</code>
|
||||||
文件中添加以下配置:
|
文件开头添加以下配置:
|
||||||
</p>
|
</p>
|
||||||
<div
|
<div
|
||||||
class="overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
|
class="overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
|
||||||
@@ -1478,7 +1478,7 @@
|
|||||||
<p class="mb-3 text-sm text-yellow-700">
|
<p class="mb-3 text-sm text-yellow-700">
|
||||||
在
|
在
|
||||||
<code class="rounded bg-yellow-100 px-1">~/.codex/config.toml</code>
|
<code class="rounded bg-yellow-100 px-1">~/.codex/config.toml</code>
|
||||||
文件中添加以下配置:
|
文件开头添加以下配置:
|
||||||
</p>
|
</p>
|
||||||
<div
|
<div
|
||||||
class="overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
|
class="overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
|
||||||
|
|||||||
Reference in New Issue
Block a user