Merge branch 'dev'

This commit is contained in:
shaw
2025-09-28 23:35:13 +08:00
19 changed files with 1743 additions and 524 deletions

View File

@@ -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"

View File

@@ -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
`) `)
} }

View File

@@ -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

View File

@@ -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
} }
} }

View File

@@ -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)

View File

@@ -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 },

View File

@@ -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
}, },
// 模型和客户端限制信息 // 模型和客户端限制信息

View File

@@ -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', // 新增:激活时间

View File

@@ -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()

View File

@@ -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,

View File

@@ -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,

View File

@@ -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' ? '小时' : '天'}后自动过期。`
) )
} }

View File

@@ -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,39 +251,40 @@
</span> </span>
</span> </span>
</div> </div>
<div class="flex items-center justify-between"> <div class="space-y-2">
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">客户端限制</span> <div class="flex items-center justify-between">
<span class="text-sm font-medium text-gray-900 md:text-base"> <span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">客户端限制</span>
<span class="text-sm font-medium text-gray-900 md:text-base">
<span v-if="hasClientRestrictions" class="text-orange-600">
<i class="fas fa-exclamation-triangle mr-1 text-xs md:text-sm" />
限 {{ statsData.restrictions.allowedClients.length }} 种客户端使用
</span>
<span v-else class="text-green-600">
<i class="fas fa-check-circle mr-1 text-xs md:text-sm" />
允许所有客户端
</span>
</span>
</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 <span
v-if=" v-for="client in statsData.restrictions.allowedClients"
statsData.restrictions.enableClientRestriction && :key="client"
statsData.restrictions.allowedClients.length > 0 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"
"
class="text-orange-600"
> >
<i class="fas fa-exclamation-triangle mr-1 text-xs md:text-sm" /> <i class="fas fa-id-badge" />
{{ statsData.restrictions.allowedClients.length }} 种客户端使用 {{ client }}
</span> </span>
<span v-else class="text-green-600"> </div>
<i class="fas fa-check-circle mr-1 text-xs md:text-sm" />
允许所有客户端
</span>
</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,72 +292,36 @@
详细限制信息 详细限制信息
</h3> </h3>
<div class="grid grid-cols-1 gap-4 md:gap-6 lg:grid-cols-2"> <div
<!-- 模型限制详情 --> class="rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-800 dark:bg-amber-900/20 md:p-4"
<div >
v-if=" <h4
statsData.restrictions.enableModelRestriction && class="mb-2 flex items-center text-sm font-bold text-amber-800 dark:text-amber-300 md:mb-3 md:text-base"
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"
> >
<h4 <i class="fas fa-robot mr-1 text-xs md:mr-2 md:text-sm" />
class="mb-2 flex items-center text-sm font-bold text-amber-800 dark:text-amber-300 md:mb-3 md:text-base" 受限模型列表
</h4>
<div class="space-y-1 md:space-y-2">
<div
v-for="model in statsData.restrictions.restrictedModels"
:key="model"
class="rounded border border-amber-200 bg-white px-2 py-1 text-xs dark:border-amber-700 dark:bg-gray-800 md:px-3 md:py-2 md:text-sm"
> >
<i class="fas fa-robot mr-1 text-xs md:mr-2 md:text-sm" /> <i class="fas fa-ban mr-1 text-xs text-red-500 md:mr-2" />
受限模型列表 <span class="break-all text-gray-800 dark:text-gray-200">{{ model }}</span>
</h4>
<div class="space-y-1 md:space-y-2">
<div
v-for="model in statsData.restrictions.restrictedModels"
:key="model"
class="rounded border border-amber-200 bg-white px-2 py-1 text-xs dark:border-amber-700 dark:bg-gray-800 md:px-3 md:py-2 md:text-sm"
>
<i class="fas fa-ban mr-1 text-xs text-red-500 md:mr-2" />
<span class="break-all text-gray-800 dark:text-gray-200">{{ model }}</span>
</div>
</div> </div>
<p class="mt-2 text-xs text-amber-700 dark:text-amber-400 md:mt-3">
<i class="fas fa-info-circle mr-1" />
此 API Key 不能访问以上列出的模型
</p>
</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>
<p class="mt-2 text-xs text-amber-700 dark:text-amber-400 md:mt-3">
<i class="fas fa-info-circle mr-1" />
API Key 不能访问以上列出的模型
</p>
</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)

View File

@@ -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">
<i <header class="section-header">
class="mr-2 text-sm md:mr-3 md:text-base" <i
:class=" class="header-icon"
multiKeyMode ? 'fas fa-layer-group text-purple-500' : 'fas fa-info-circle text-blue-500' :class="
" multiKeyMode
/> ? 'fas fa-layer-group text-purple-500'
{{ multiKeyMode ? '批量查询概要' : 'API Key 信息' }} : 'fas fa-info-circle text-blue-500'
</h3> "
/>
<h3 class="header-title">{{ multiKeyMode ? '批量查询概要' : 'API Key 信息' }}</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"> </div>
{{ aggregatedStats.totalKeys }} <div class="info-item">
</span> <p class="info-label">有效 Keys </p>
</div> <p class="info-value text-green-600 dark:text-emerald-400">
<div class="flex items-center justify-between"> <i class="fas fa-check-circle mr-1" />{{ aggregatedStats.activeKeys }}
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">有效 Keys </span> </p>
<span class="text-sm font-medium text-green-600 md:text-base"> </div>
<i class="fas fa-check-circle mr-1 text-xs md:text-sm" /> <div v-if="invalidKeys.length > 0" class="info-item">
{{ aggregatedStats.activeKeys }} <p class="info-label">无效 Keys </p>
</span> <p class="info-value text-red-500 dark:text-red-400">
</div> <i class="fas fa-times-circle mr-1" />{{ invalidKeys.length }}
<div v-if="invalidKeys.length > 0" class="flex items-center justify-between"> </p>
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">无效 Keys </span> </div>
<span class="text-sm font-medium text-red-600 md:text-base"> <div class="info-item">
<i class="fas fa-times-circle mr-1 text-xs md:text-sm" /> <p class="info-label">总请求数</p>
{{ invalidKeys.length }} <p class="info-value">{{ formatNumber(aggregatedStats.usage.requests) }}</p>
</span> </div>
</div> <div class="info-item">
<div class="flex items-center justify-between"> <p class="info-label"> Token </p>
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">总请求数</span> <p class="info-value">{{ formatNumber(aggregatedStats.usage.allTokens) }}</p>
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 md:text-base"> </div>
{{ formatNumber(aggregatedStats.usage.requests) }} <div class="info-item">
</span> <p class="info-label">总费用</p>
</div> <p class="info-value text-indigo-600 dark:text-indigo-300">
<div class="flex items-center justify-between"> {{ aggregatedStats.usage.formattedCost }}
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base"> Token </span> </p>
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 md:text-base"> </div>
{{ formatNumber(aggregatedStats.usage.allTokens) }} <div v-if="individualStats.length > 1" class="info-item xl:col-span-2">
</span> <p class="info-label">Top 3 贡献占比</p>
</div> <div class="space-y-2">
<div class="flex items-center justify-between"> <div v-for="stat in topContributors" :key="stat.apiId" class="contributor-item">
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">总费用</span> <span class="truncate">{{ stat.name }}</span>
<span class="text-sm font-medium text-indigo-600 md:text-base"> <span class="font-semibold">{{ calculateContribution(stat) }}%</span>
{{ aggregatedStats.usage.formattedCost }} </div>
</span> </div>
</div>
</div> </div>
<!-- Key 贡献占比可选 --> <div v-else class="info-grid">
<div <div class="info-item">
v-if="individualStats.length > 1" <p class="info-label">名称</p>
class="border-t border-gray-200 pt-2 dark:border-gray-700" <p class="info-value break-all">{{ statsData.name }}</p>
> </div>
<div class="mb-2 text-xs text-gray-500 dark:text-gray-400"> Key 贡献占比</div> <div class="info-item">
<div class="space-y-1"> <p class="info-label">状态</p>
<div <p
v-for="stat in topContributors" class="info-value font-semibold"
:key="stat.apiId" :class="
class="flex items-center justify-between text-xs" statsData.isActive
? 'text-green-600 dark:text-emerald-400'
: 'text-red-500 dark:text-red-400'
"
> >
<span class="truncate text-gray-600 dark:text-gray-400">{{ stat.name }}</span> <i
<span class="text-gray-900 dark:text-gray-100" class="mr-1"
>{{ calculateContribution(stat) }}%</span :class="statsData.isActive ? 'fas fa-check-circle' : 'fas fa-times-circle'"
> />
{{ 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 class="ml-2 text-xs text-gray-500 dark:text-gray-400">
首次使用后
{{ statsData.activationDays || (statsData.activationUnit === 'hours' ? 24 : 30) }}
{{ statsData.activationUnit === 'hours' ? '小时' : '天' }}过期
</span>
</template>
<template v-else-if="statsData.expiresAt">
<span
v-if="isApiKeyExpired(statsData.expiresAt)"
class="text-red-500 dark:text-red-400"
>
<i class="fas fa-exclamation-circle mr-1" />已过期
</span>
<span
v-else-if="isApiKeyExpiringSoon(statsData.expiresAt)"
class="text-orange-500 dark:text-orange-400"
>
<i class="fas fa-clock mr-1" />{{ formatExpireDate(statsData.expiresAt) }}
</span>
<span v-else>{{ formatExpireDate(statsData.expiresAt) }}</span>
</template>
<template v-else>
<span class="text-gray-400 dark:text-gray-500">
<i class="fas fa-infinity mr-1" />永不过期
</span>
</template>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Key 模式下的详细信息 --> <!-- 使用统计概览 -->
<div v-else class="space-y-2 md:space-y-3"> <div class="card-section">
<div class="flex items-center justify-between"> <header class="section-header">
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">名称</span> <i class="header-icon fas fa-chart-bar text-green-500" />
<span <h3 class="header-title">使用统计概览</h3>
class="break-all text-sm font-medium text-gray-900 dark:text-gray-100 md:text-base" <span class="header-tag">{{ statsPeriod === 'daily' ? '今日' : '本月' }}</span>
>{{ statsData.name }}</span </header>
> <div class="metric-grid">
</div> <div class="metric-card">
<div class="flex items-center justify-between"> <p class="metric-value text-green-600 dark:text-emerald-300">
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">状态</span> {{ formatNumber(currentPeriodData.requests) }}
<span </p>
class="text-sm font-medium md:text-base" <p class="metric-label">{{ statsPeriod === 'daily' ? '今日' : '本月' }}请求数</p>
:class="statsData.isActive ? 'text-green-600' : 'text-red-600'"
>
<i
class="mr-1 text-xs md:text-sm"
:class="statsData.isActive ? 'fas fa-check-circle' : 'fas fa-times-circle'"
/>
{{ statsData.isActive ? '活跃' : '已停用' }}
</span>
</div>
<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 font-medium text-gray-900 dark:text-gray-100 md:text-base">{{
formatPermissions(statsData.permissions)
}}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400 md:text-base">创建时间</span>
<span
class="break-all text-xs font-medium text-gray-900 dark:text-gray-100 md:text-base"
>{{ formatDate(statsData.createdAt) }}</span
>
</div>
<div class="flex items-start justify-between">
<span class="mt-1 flex-shrink-0 text-sm text-gray-600 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>
<!-- 已设置过期时间 --> <div class="metric-card">
<div v-else-if="statsData.expiresAt" class="text-right"> <p class="metric-value text-blue-600 dark:text-sky-300">
<div {{ formatNumber(currentPeriodData.allTokens) }}
v-if="isApiKeyExpired(statsData.expiresAt)" </p>
class="text-sm font-medium text-red-600 md:text-base" <p class="metric-label">{{ statsPeriod === 'daily' ? '今日' : '本月' }}Token </p>
>
<i class="fas fa-exclamation-circle mr-1 text-xs md:text-sm" />
已过期
</div>
<div
v-else-if="isApiKeyExpiringSoon(statsData.expiresAt)"
class="break-all text-xs font-medium text-orange-600 md:text-base"
>
<i class="fas fa-clock mr-1 text-xs md:text-sm" />
{{ formatExpireDate(statsData.expiresAt) }}
</div>
<div
v-else
class="break-all text-xs font-medium text-gray-900 dark:text-gray-100 md:text-base"
>
{{ formatExpireDate(statsData.expiresAt) }}
</div>
</div> </div>
<!-- 永不过期 --> <div class="metric-card">
<div v-else class="text-sm font-medium text-gray-400 dark:text-gray-500 md:text-base"> <p class="metric-value text-purple-600 dark:text-violet-300">
<i class="fas fa-infinity mr-1 text-xs md:text-sm" /> {{ currentPeriodData.formattedCost || '$0.000000' }}
永不过期 </p>
<p class="metric-label">{{ statsPeriod === 'daily' ? '今日' : '本月' }}费用</p>
</div>
<div class="metric-card">
<p class="metric-value text-amber-500 dark:text-amber-300">
{{ formatNumber(currentPeriodData.inputTokens) }}
</p>
<p class="metric-label">{{ statsPeriod === 'daily' ? '今日' : '本月' }}输入 Token</p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- 使用统计概览 --> <!-- 专属账号运行状态仅在单 key 且存在绑定时显示 -->
<div class="card p-4 md:p-6"> <div v-if="!multiKeyMode && boundAccountList.length > 0" 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-plug text-indigo-500" />
> <h3 class="header-title">专属账号运行状态</h3>
<span class="flex items-center"> <span class="header-tag">实时更新</span>
<i class="fas fa-chart-bar mr-2 text-sm text-green-500 md:mr-3 md:text-base" /> </header>
使用统计概览
</span> <div class="grid grid-cols-1 gap-4" :class="accountGridClass">
<span class="text-xs font-normal text-gray-600 dark:text-gray-400 sm:ml-2 md:text-sm" <div
>({{ statsPeriod === 'daily' ? '今日' : '本月' }})</span v-for="account in boundAccountList"
:key="account.id || account.key"
class="account-card"
> >
</h3> <div class="flex items-center justify-between gap-3">
<div class="grid grid-cols-2 gap-3 md:gap-4"> <div class="flex items-center gap-3">
<div class="stat-card text-center"> <span
<div class="text-lg font-bold text-green-600 md:text-3xl"> class="account-icon"
{{ formatNumber(currentPeriodData.requests) }} :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>
<div class="text-xs text-gray-600 dark:text-gray-400 md:text-sm">
{{ statsPeriod === 'daily' ? '今日' : '本月' }}请求数 <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>
</div>
<div class="stat-card text-center"> <div v-else-if="account.platform === 'openai'" class="mt-3">
<div class="text-lg font-bold text-blue-600 md:text-3xl"> <div v-if="account.codexUsage" class="space-y-2">
{{ formatNumber(currentPeriodData.allTokens) }} <div
</div> v-for="type in ['primary', 'secondary']"
<div class="text-xs text-gray-600 dark:text-gray-400 md:text-sm"> :key="`${account.key}-${type}`"
{{ statsPeriod === 'daily' ? '今日' : '本月' }}Token数 class="quota-row"
</div> >
</div> <div class="quota-header">
<div class="stat-card text-center"> <span class="quota-tag" :class="type === 'primary' ? 'tag-indigo' : 'tag-blue'">
<div class="text-lg font-bold text-purple-600 md:text-3xl"> {{ getCodexWindowLabel(type) }}
{{ currentPeriodData.formattedCost || '$0.000000' }} </span>
</div> <span class="quota-percent">
<div class="text-xs text-gray-600 dark:text-gray-400 md:text-sm"> {{ formatCodexUsagePercent(account.codexUsage?.[type]) }}
{{ statsPeriod === 'daily' ? '今日' : '本月' }}费用 </span>
</div> </div>
</div> <div class="progress-track">
<div class="stat-card text-center"> <div
<div class="text-lg font-bold text-yellow-600 md:text-3xl"> class="progress-bar"
{{ formatNumber(currentPeriodData.inputTokens) }} :class="getCodexUsageBarClass(account.codexUsage?.[type])"
</div> :style="{ width: getCodexUsageWidth(account.codexUsage?.[type]) }"
<div class="text-xs text-gray-600 dark:text-gray-400 md:text-sm"> />
{{ statsPeriod === 'daily' ? '今日' : '本月' }}输入Token </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.toLocaleString()
return (num / 1000000).toFixed(1) + 'M'
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K'
} else {
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>

View File

@@ -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);

View File

@@ -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,49 +2303,112 @@ const deleteAccount = async (account) => {
if (!confirmed) return if (!confirmed) return
try { const result = await performAccountDeletion(account)
let endpoint
if (account.platform === 'claude') { if (result.success) {
endpoint = `/admin/claude-accounts/${account.id}` const data = result.data
} else if (account.platform === 'claude-console') { let toastMessage = '账户已成功删除'
endpoint = `/admin/claude-console-accounts/${account.id}` if (data?.unboundKeys > 0) {
} else if (account.platform === 'bedrock') { toastMessage += `${data.unboundKeys} 个 API Key 已切换为共享池模式`
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}`
} }
showToast(toastMessage, 'success')
const data = await apiClient.delete(endpoint) selectedAccounts.value = selectedAccounts.value.filter((id) => id !== account.id)
updateSelectAllState()
if (data.success) { groupMembersLoaded.value = false
// 根据解绑结果显示不同的消息 apiKeysLoaded.value = false
let toastMessage = '账户已成功删除' loadAccounts()
if (data.unboundKeys > 0) { loadApiKeys(true)
toastMessage += `${data.unboundKeys} 个 API Key 已切换为共享池模式` } else {
} showToast(result.message || '删除失败', 'error')
showToast(toastMessage, 'success')
// 清空相关缓存
groupMembersLoaded.value = false
apiKeysLoaded.value = false // 重新加载API Keys以反映解绑变化
loadAccounts()
loadApiKeys(true) // 强制重新加载API Keys
} else {
showToast(data.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()
}
// 重置账户状态 // 重置账户状态
const resetAccountStatus = async (account) => { const resetAccountStatus = async (account) => {
if (account.isResetting) return if (account.isResetting) return
@@ -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)

View File

@@ -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

View File

@@ -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>
<!-- 模型使用统计 --> <!-- 模型使用统计 -->

View File

@@ -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"