mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 00:53:33 +00:00
Merge branch 'main' of https://github.com/Wei-Shaw/claude-relay-service
This commit is contained in:
@@ -282,6 +282,104 @@ class RedisClient {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 📊 记录账户级别的使用统计
|
||||||
|
async incrementAccountUsage(accountId, totalTokens, inputTokens = 0, outputTokens = 0, cacheCreateTokens = 0, cacheReadTokens = 0, model = 'unknown') {
|
||||||
|
const now = new Date();
|
||||||
|
const today = getDateStringInTimezone(now);
|
||||||
|
const tzDate = getDateInTimezone(now);
|
||||||
|
const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`;
|
||||||
|
const currentHour = `${today}:${String(getHourInTimezone(now)).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
// 账户级别统计的键
|
||||||
|
const accountKey = `account_usage:${accountId}`;
|
||||||
|
const accountDaily = `account_usage:daily:${accountId}:${today}`;
|
||||||
|
const accountMonthly = `account_usage:monthly:${accountId}:${currentMonth}`;
|
||||||
|
const accountHourly = `account_usage:hourly:${accountId}:${currentHour}`;
|
||||||
|
|
||||||
|
// 账户按模型统计的键
|
||||||
|
const accountModelDaily = `account_usage:model:daily:${accountId}:${model}:${today}`;
|
||||||
|
const accountModelMonthly = `account_usage:model:monthly:${accountId}:${model}:${currentMonth}`;
|
||||||
|
const accountModelHourly = `account_usage:model:hourly:${accountId}:${model}:${currentHour}`;
|
||||||
|
|
||||||
|
// 处理token分配
|
||||||
|
const finalInputTokens = inputTokens || 0;
|
||||||
|
const finalOutputTokens = outputTokens || 0;
|
||||||
|
const finalCacheCreateTokens = cacheCreateTokens || 0;
|
||||||
|
const finalCacheReadTokens = cacheReadTokens || 0;
|
||||||
|
const actualTotalTokens = finalInputTokens + finalOutputTokens + finalCacheCreateTokens + finalCacheReadTokens;
|
||||||
|
const coreTokens = finalInputTokens + finalOutputTokens;
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
// 账户总体统计
|
||||||
|
this.client.hincrby(accountKey, 'totalTokens', coreTokens),
|
||||||
|
this.client.hincrby(accountKey, 'totalInputTokens', finalInputTokens),
|
||||||
|
this.client.hincrby(accountKey, 'totalOutputTokens', finalOutputTokens),
|
||||||
|
this.client.hincrby(accountKey, 'totalCacheCreateTokens', finalCacheCreateTokens),
|
||||||
|
this.client.hincrby(accountKey, 'totalCacheReadTokens', finalCacheReadTokens),
|
||||||
|
this.client.hincrby(accountKey, 'totalAllTokens', actualTotalTokens),
|
||||||
|
this.client.hincrby(accountKey, 'totalRequests', 1),
|
||||||
|
|
||||||
|
// 账户每日统计
|
||||||
|
this.client.hincrby(accountDaily, 'tokens', coreTokens),
|
||||||
|
this.client.hincrby(accountDaily, 'inputTokens', finalInputTokens),
|
||||||
|
this.client.hincrby(accountDaily, 'outputTokens', finalOutputTokens),
|
||||||
|
this.client.hincrby(accountDaily, 'cacheCreateTokens', finalCacheCreateTokens),
|
||||||
|
this.client.hincrby(accountDaily, 'cacheReadTokens', finalCacheReadTokens),
|
||||||
|
this.client.hincrby(accountDaily, 'allTokens', actualTotalTokens),
|
||||||
|
this.client.hincrby(accountDaily, 'requests', 1),
|
||||||
|
|
||||||
|
// 账户每月统计
|
||||||
|
this.client.hincrby(accountMonthly, 'tokens', coreTokens),
|
||||||
|
this.client.hincrby(accountMonthly, 'inputTokens', finalInputTokens),
|
||||||
|
this.client.hincrby(accountMonthly, 'outputTokens', finalOutputTokens),
|
||||||
|
this.client.hincrby(accountMonthly, 'cacheCreateTokens', finalCacheCreateTokens),
|
||||||
|
this.client.hincrby(accountMonthly, 'cacheReadTokens', finalCacheReadTokens),
|
||||||
|
this.client.hincrby(accountMonthly, 'allTokens', actualTotalTokens),
|
||||||
|
this.client.hincrby(accountMonthly, 'requests', 1),
|
||||||
|
|
||||||
|
// 账户每小时统计
|
||||||
|
this.client.hincrby(accountHourly, 'tokens', coreTokens),
|
||||||
|
this.client.hincrby(accountHourly, 'inputTokens', finalInputTokens),
|
||||||
|
this.client.hincrby(accountHourly, 'outputTokens', finalOutputTokens),
|
||||||
|
this.client.hincrby(accountHourly, 'cacheCreateTokens', finalCacheCreateTokens),
|
||||||
|
this.client.hincrby(accountHourly, 'cacheReadTokens', finalCacheReadTokens),
|
||||||
|
this.client.hincrby(accountHourly, 'allTokens', actualTotalTokens),
|
||||||
|
this.client.hincrby(accountHourly, 'requests', 1),
|
||||||
|
|
||||||
|
// 账户按模型统计 - 每日
|
||||||
|
this.client.hincrby(accountModelDaily, 'inputTokens', finalInputTokens),
|
||||||
|
this.client.hincrby(accountModelDaily, 'outputTokens', finalOutputTokens),
|
||||||
|
this.client.hincrby(accountModelDaily, 'cacheCreateTokens', finalCacheCreateTokens),
|
||||||
|
this.client.hincrby(accountModelDaily, 'cacheReadTokens', finalCacheReadTokens),
|
||||||
|
this.client.hincrby(accountModelDaily, 'allTokens', actualTotalTokens),
|
||||||
|
this.client.hincrby(accountModelDaily, 'requests', 1),
|
||||||
|
|
||||||
|
// 账户按模型统计 - 每月
|
||||||
|
this.client.hincrby(accountModelMonthly, 'inputTokens', finalInputTokens),
|
||||||
|
this.client.hincrby(accountModelMonthly, 'outputTokens', finalOutputTokens),
|
||||||
|
this.client.hincrby(accountModelMonthly, 'cacheCreateTokens', finalCacheCreateTokens),
|
||||||
|
this.client.hincrby(accountModelMonthly, 'cacheReadTokens', finalCacheReadTokens),
|
||||||
|
this.client.hincrby(accountModelMonthly, 'allTokens', actualTotalTokens),
|
||||||
|
this.client.hincrby(accountModelMonthly, 'requests', 1),
|
||||||
|
|
||||||
|
// 账户按模型统计 - 每小时
|
||||||
|
this.client.hincrby(accountModelHourly, 'inputTokens', finalInputTokens),
|
||||||
|
this.client.hincrby(accountModelHourly, 'outputTokens', finalOutputTokens),
|
||||||
|
this.client.hincrby(accountModelHourly, 'cacheCreateTokens', finalCacheCreateTokens),
|
||||||
|
this.client.hincrby(accountModelHourly, 'cacheReadTokens', finalCacheReadTokens),
|
||||||
|
this.client.hincrby(accountModelHourly, 'allTokens', actualTotalTokens),
|
||||||
|
this.client.hincrby(accountModelHourly, 'requests', 1),
|
||||||
|
|
||||||
|
// 设置过期时间
|
||||||
|
this.client.expire(accountDaily, 86400 * 32), // 32天过期
|
||||||
|
this.client.expire(accountMonthly, 86400 * 365), // 1年过期
|
||||||
|
this.client.expire(accountHourly, 86400 * 7), // 7天过期
|
||||||
|
this.client.expire(accountModelDaily, 86400 * 32), // 32天过期
|
||||||
|
this.client.expire(accountModelMonthly, 86400 * 365), // 1年过期
|
||||||
|
this.client.expire(accountModelHourly, 86400 * 7) // 7天过期
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
async getUsageStats(keyId) {
|
async getUsageStats(keyId) {
|
||||||
const totalKey = `usage:${keyId}`;
|
const totalKey = `usage:${keyId}`;
|
||||||
const today = getDateStringInTimezone();
|
const today = getDateStringInTimezone();
|
||||||
@@ -369,6 +467,110 @@ class RedisClient {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 📊 获取账户使用统计
|
||||||
|
async getAccountUsageStats(accountId) {
|
||||||
|
const accountKey = `account_usage:${accountId}`;
|
||||||
|
const today = getDateStringInTimezone();
|
||||||
|
const accountDailyKey = `account_usage:daily:${accountId}:${today}`;
|
||||||
|
const tzDate = getDateInTimezone();
|
||||||
|
const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`;
|
||||||
|
const accountMonthlyKey = `account_usage:monthly:${accountId}:${currentMonth}`;
|
||||||
|
|
||||||
|
const [total, daily, monthly] = await Promise.all([
|
||||||
|
this.client.hgetall(accountKey),
|
||||||
|
this.client.hgetall(accountDailyKey),
|
||||||
|
this.client.hgetall(accountMonthlyKey)
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 获取账户创建时间来计算平均值
|
||||||
|
const accountData = await this.client.hgetall(`claude_account:${accountId}`);
|
||||||
|
const createdAt = accountData.createdAt ? new Date(accountData.createdAt) : new Date();
|
||||||
|
const now = new Date();
|
||||||
|
const daysSinceCreated = Math.max(1, Math.ceil((now - createdAt) / (1000 * 60 * 60 * 24)));
|
||||||
|
|
||||||
|
const totalTokens = parseInt(total.totalTokens) || 0;
|
||||||
|
const totalRequests = parseInt(total.totalRequests) || 0;
|
||||||
|
|
||||||
|
// 计算平均RPM和TPM
|
||||||
|
const totalMinutes = Math.max(1, daysSinceCreated * 24 * 60);
|
||||||
|
const avgRPM = totalRequests / totalMinutes;
|
||||||
|
const avgTPM = totalTokens / totalMinutes;
|
||||||
|
|
||||||
|
// 处理账户统计数据
|
||||||
|
const handleAccountData = (data) => {
|
||||||
|
const tokens = parseInt(data.totalTokens) || parseInt(data.tokens) || 0;
|
||||||
|
const inputTokens = parseInt(data.totalInputTokens) || parseInt(data.inputTokens) || 0;
|
||||||
|
const outputTokens = parseInt(data.totalOutputTokens) || parseInt(data.outputTokens) || 0;
|
||||||
|
const requests = parseInt(data.totalRequests) || parseInt(data.requests) || 0;
|
||||||
|
const cacheCreateTokens = parseInt(data.totalCacheCreateTokens) || parseInt(data.cacheCreateTokens) || 0;
|
||||||
|
const cacheReadTokens = parseInt(data.totalCacheReadTokens) || parseInt(data.cacheReadTokens) || 0;
|
||||||
|
const allTokens = parseInt(data.totalAllTokens) || parseInt(data.allTokens) || 0;
|
||||||
|
|
||||||
|
const actualAllTokens = allTokens || (inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tokens: tokens,
|
||||||
|
inputTokens: inputTokens,
|
||||||
|
outputTokens: outputTokens,
|
||||||
|
cacheCreateTokens: cacheCreateTokens,
|
||||||
|
cacheReadTokens: cacheReadTokens,
|
||||||
|
allTokens: actualAllTokens,
|
||||||
|
requests: requests
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalData = handleAccountData(total);
|
||||||
|
const dailyData = handleAccountData(daily);
|
||||||
|
const monthlyData = handleAccountData(monthly);
|
||||||
|
|
||||||
|
return {
|
||||||
|
accountId: accountId,
|
||||||
|
total: totalData,
|
||||||
|
daily: dailyData,
|
||||||
|
monthly: monthlyData,
|
||||||
|
averages: {
|
||||||
|
rpm: Math.round(avgRPM * 100) / 100,
|
||||||
|
tpm: Math.round(avgTPM * 100) / 100,
|
||||||
|
dailyRequests: Math.round((totalRequests / daysSinceCreated) * 100) / 100,
|
||||||
|
dailyTokens: Math.round((totalTokens / daysSinceCreated) * 100) / 100
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 📈 获取所有账户的使用统计
|
||||||
|
async getAllAccountsUsageStats() {
|
||||||
|
try {
|
||||||
|
// 获取所有Claude账户
|
||||||
|
const accountKeys = await this.client.keys('claude_account:*');
|
||||||
|
const accountStats = [];
|
||||||
|
|
||||||
|
for (const accountKey of accountKeys) {
|
||||||
|
const accountId = accountKey.replace('claude_account:', '');
|
||||||
|
const accountData = await this.client.hgetall(accountKey);
|
||||||
|
|
||||||
|
if (accountData.name) {
|
||||||
|
const stats = await this.getAccountUsageStats(accountId);
|
||||||
|
accountStats.push({
|
||||||
|
id: accountId,
|
||||||
|
name: accountData.name,
|
||||||
|
email: accountData.email || '',
|
||||||
|
status: accountData.status || 'unknown',
|
||||||
|
isActive: accountData.isActive === 'true',
|
||||||
|
...stats
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按当日token使用量排序
|
||||||
|
accountStats.sort((a, b) => (b.daily.allTokens || 0) - (a.daily.allTokens || 0));
|
||||||
|
|
||||||
|
return accountStats;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get all accounts usage stats:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 🧹 清空所有API Key的使用统计数据
|
// 🧹 清空所有API Key的使用统计数据
|
||||||
async resetAllUsageStats() {
|
async resetAllUsageStats() {
|
||||||
const client = this.getClientSafe();
|
const client = this.getClientSafe();
|
||||||
|
|||||||
@@ -575,7 +575,34 @@ router.post('/claude-accounts/exchange-code', authenticateAdmin, async (req, res
|
|||||||
router.get('/claude-accounts', authenticateAdmin, async (req, res) => {
|
router.get('/claude-accounts', authenticateAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const accounts = await claudeAccountService.getAllAccounts();
|
const accounts = await claudeAccountService.getAllAccounts();
|
||||||
res.json({ success: true, data: accounts });
|
|
||||||
|
// 为每个账户添加使用统计信息
|
||||||
|
const accountsWithStats = await Promise.all(accounts.map(async (account) => {
|
||||||
|
try {
|
||||||
|
const usageStats = await redis.getAccountUsageStats(account.id);
|
||||||
|
return {
|
||||||
|
...account,
|
||||||
|
usage: {
|
||||||
|
daily: usageStats.daily,
|
||||||
|
total: usageStats.total,
|
||||||
|
averages: usageStats.averages
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (statsError) {
|
||||||
|
logger.warn(`⚠️ Failed to get usage stats for account ${account.id}:`, statsError.message);
|
||||||
|
// 如果获取统计失败,返回空统计
|
||||||
|
return {
|
||||||
|
...account,
|
||||||
|
usage: {
|
||||||
|
daily: { tokens: 0, requests: 0, allTokens: 0 },
|
||||||
|
total: { tokens: 0, requests: 0, allTokens: 0 },
|
||||||
|
averages: { rpm: 0, tpm: 0 }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json({ success: true, data: accountsWithStats });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ Failed to get Claude accounts:', error);
|
logger.error('❌ Failed to get Claude accounts:', error);
|
||||||
res.status(500).json({ error: 'Failed to get Claude accounts', message: error.message });
|
res.status(500).json({ error: 'Failed to get Claude accounts', message: error.message });
|
||||||
@@ -762,7 +789,18 @@ router.post('/gemini-accounts/exchange-code', authenticateAdmin, async (req, res
|
|||||||
router.get('/gemini-accounts', authenticateAdmin, async (req, res) => {
|
router.get('/gemini-accounts', authenticateAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const accounts = await geminiAccountService.getAllAccounts();
|
const accounts = await geminiAccountService.getAllAccounts();
|
||||||
res.json({ success: true, data: accounts });
|
|
||||||
|
// 为Gemini账户添加空的使用统计(暂时)
|
||||||
|
const accountsWithStats = accounts.map(account => ({
|
||||||
|
...account,
|
||||||
|
usage: {
|
||||||
|
daily: { tokens: 0, requests: 0, allTokens: 0 },
|
||||||
|
total: { tokens: 0, requests: 0, allTokens: 0 },
|
||||||
|
averages: { rpm: 0, tpm: 0 }
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json({ success: true, data: accountsWithStats });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ Failed to get Gemini accounts:', error);
|
logger.error('❌ Failed to get Gemini accounts:', error);
|
||||||
res.status(500).json({ error: 'Failed to get accounts', message: error.message });
|
res.status(500).json({ error: 'Failed to get accounts', message: error.message });
|
||||||
@@ -835,6 +873,73 @@ router.post('/gemini-accounts/:accountId/refresh', authenticateAdmin, async (req
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 📊 账户使用统计
|
||||||
|
|
||||||
|
// 获取所有账户的使用统计
|
||||||
|
router.get('/accounts/usage-stats', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const accountsStats = await redis.getAllAccountsUsageStats();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: accountsStats,
|
||||||
|
summary: {
|
||||||
|
totalAccounts: accountsStats.length,
|
||||||
|
activeToday: accountsStats.filter(account => account.daily.requests > 0).length,
|
||||||
|
totalDailyTokens: accountsStats.reduce((sum, account) => sum + (account.daily.allTokens || 0), 0),
|
||||||
|
totalDailyRequests: accountsStats.reduce((sum, account) => sum + (account.daily.requests || 0), 0)
|
||||||
|
},
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get accounts usage stats:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to get accounts usage stats',
|
||||||
|
message: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取单个账户的使用统计
|
||||||
|
router.get('/accounts/:accountId/usage-stats', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { accountId } = req.params;
|
||||||
|
const accountStats = await redis.getAccountUsageStats(accountId);
|
||||||
|
|
||||||
|
// 获取账户基本信息
|
||||||
|
const accountData = await claudeAccountService.getAccount(accountId);
|
||||||
|
if (!accountData) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Account not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
...accountStats,
|
||||||
|
accountInfo: {
|
||||||
|
name: accountData.name,
|
||||||
|
email: accountData.email,
|
||||||
|
status: accountData.status,
|
||||||
|
isActive: accountData.isActive,
|
||||||
|
createdAt: accountData.createdAt
|
||||||
|
}
|
||||||
|
},
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get account usage stats:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to get account usage stats',
|
||||||
|
message: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 📊 系统统计
|
// 📊 系统统计
|
||||||
|
|
||||||
// 获取系统概览
|
// 获取系统概览
|
||||||
|
|||||||
@@ -68,8 +68,9 @@ async function handleMessagesRequest(req, res) {
|
|||||||
const cacheReadTokens = usageData.cache_read_input_tokens || 0;
|
const cacheReadTokens = usageData.cache_read_input_tokens || 0;
|
||||||
const model = usageData.model || 'unknown';
|
const model = usageData.model || 'unknown';
|
||||||
|
|
||||||
// 记录真实的token使用量(包含模型信息和所有4种token)
|
// 记录真实的token使用量(包含模型信息和所有4种token以及账户ID)
|
||||||
apiKeyService.recordUsage(req.apiKey.id, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model).catch(error => {
|
const accountId = usageData.accountId;
|
||||||
|
apiKeyService.recordUsage(req.apiKey.id, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model, accountId).catch(error => {
|
||||||
logger.error('❌ Failed to record stream usage:', error);
|
logger.error('❌ Failed to record stream usage:', error);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -135,8 +136,9 @@ async function handleMessagesRequest(req, res) {
|
|||||||
const cacheReadTokens = jsonData.usage.cache_read_input_tokens || 0;
|
const cacheReadTokens = jsonData.usage.cache_read_input_tokens || 0;
|
||||||
const model = jsonData.model || req.body.model || 'unknown';
|
const model = jsonData.model || req.body.model || 'unknown';
|
||||||
|
|
||||||
// 记录真实的token使用量(包含模型信息和所有4种token)
|
// 记录真实的token使用量(包含模型信息和所有4种token以及账户ID)
|
||||||
await apiKeyService.recordUsage(req.apiKey.id, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model);
|
const accountId = response.accountId;
|
||||||
|
await apiKeyService.recordUsage(req.apiKey.id, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model, accountId);
|
||||||
|
|
||||||
// 更新时间窗口内的token计数
|
// 更新时间窗口内的token计数
|
||||||
if (req.rateLimitInfo) {
|
if (req.rateLimitInfo) {
|
||||||
|
|||||||
@@ -258,7 +258,8 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
|||||||
outputTokens,
|
outputTokens,
|
||||||
cacheCreateTokens,
|
cacheCreateTokens,
|
||||||
cacheReadTokens,
|
cacheReadTokens,
|
||||||
model
|
model,
|
||||||
|
accountId
|
||||||
).catch(error => {
|
).catch(error => {
|
||||||
logger.error('❌ Failed to record usage:', error);
|
logger.error('❌ Failed to record usage:', error);
|
||||||
});
|
});
|
||||||
@@ -327,7 +328,8 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
|||||||
usage.output_tokens || 0,
|
usage.output_tokens || 0,
|
||||||
usage.cache_creation_input_tokens || 0,
|
usage.cache_creation_input_tokens || 0,
|
||||||
usage.cache_read_input_tokens || 0,
|
usage.cache_read_input_tokens || 0,
|
||||||
claudeRequest.model
|
claudeRequest.model,
|
||||||
|
accountId
|
||||||
).catch(error => {
|
).catch(error => {
|
||||||
logger.error('❌ Failed to record usage:', error);
|
logger.error('❌ Failed to record usage:', error);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -256,18 +256,28 @@ class ApiKeyService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 📊 记录使用情况(支持缓存token)
|
// 📊 记录使用情况(支持缓存token和账户级别统计)
|
||||||
async recordUsage(keyId, inputTokens = 0, outputTokens = 0, cacheCreateTokens = 0, cacheReadTokens = 0, model = 'unknown') {
|
async recordUsage(keyId, inputTokens = 0, outputTokens = 0, cacheCreateTokens = 0, cacheReadTokens = 0, model = 'unknown', accountId = null) {
|
||||||
try {
|
try {
|
||||||
const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens;
|
const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens;
|
||||||
|
|
||||||
|
// 记录API Key级别的使用统计
|
||||||
await redis.incrementTokenUsage(keyId, totalTokens, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model);
|
await redis.incrementTokenUsage(keyId, totalTokens, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model);
|
||||||
|
|
||||||
// 更新最后使用时间(性能优化:只在实际使用时更新)
|
// 获取API Key数据以确定关联的账户
|
||||||
const keyData = await redis.getApiKey(keyId);
|
const keyData = await redis.getApiKey(keyId);
|
||||||
if (keyData && Object.keys(keyData).length > 0) {
|
if (keyData && Object.keys(keyData).length > 0) {
|
||||||
|
// 更新最后使用时间
|
||||||
keyData.lastUsedAt = new Date().toISOString();
|
keyData.lastUsedAt = new Date().toISOString();
|
||||||
// 使用记录时不需要重新建立哈希映射
|
|
||||||
await redis.setApiKey(keyId, keyData);
|
await redis.setApiKey(keyId, keyData);
|
||||||
|
|
||||||
|
// 记录账户级别的使用统计(只统计实际处理请求的账户)
|
||||||
|
if (accountId) {
|
||||||
|
await redis.incrementAccountUsage(accountId, totalTokens, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model);
|
||||||
|
logger.database(`📊 Recorded account usage: ${accountId} - ${totalTokens} tokens (API Key: ${keyId})`);
|
||||||
|
} else {
|
||||||
|
logger.debug(`⚠️ No accountId provided for usage recording, skipping account-level statistics`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const logParts = [`Model: ${model}`, `Input: ${inputTokens}`, `Output: ${outputTokens}`];
|
const logParts = [`Model: ${model}`, `Input: ${inputTokens}`, `Output: ${outputTokens}`];
|
||||||
@@ -296,6 +306,16 @@ class ApiKeyService {
|
|||||||
return await redis.getUsageStats(keyId);
|
return await redis.getUsageStats(keyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 📊 获取账户使用统计
|
||||||
|
async getAccountUsageStats(accountId) {
|
||||||
|
return await redis.getAccountUsageStats(accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 📈 获取所有账户使用统计
|
||||||
|
async getAllAccountsUsageStats() {
|
||||||
|
return await redis.getAllAccountsUsageStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// 🧹 清理过期的API Keys
|
// 🧹 清理过期的API Keys
|
||||||
async cleanupExpiredKeys() {
|
async cleanupExpiredKeys() {
|
||||||
|
|||||||
@@ -181,6 +181,8 @@ class ClaudeRelayService {
|
|||||||
|
|
||||||
logger.info(`✅ API request completed - Key: ${apiKeyData.name}, Account: ${accountId}, Model: ${requestBody.model}, Input: ~${Math.round(inputTokens)} tokens, Output: ~${Math.round(outputTokens)} tokens`);
|
logger.info(`✅ API request completed - Key: ${apiKeyData.name}, Account: ${accountId}, Model: ${requestBody.model}, Input: ~${Math.round(inputTokens)} tokens, Output: ~${Math.round(outputTokens)} tokens`);
|
||||||
|
|
||||||
|
// 在响应中添加accountId,以便调用方记录账户级别统计
|
||||||
|
response.accountId = accountId;
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`❌ Claude relay request failed for key: ${apiKeyData.name || apiKeyData.id}:`, error.message);
|
logger.error(`❌ Claude relay request failed for key: ${apiKeyData.name || apiKeyData.id}:`, error.message);
|
||||||
@@ -619,7 +621,10 @@ class ClaudeRelayService {
|
|||||||
const proxyAgent = await this._getProxyAgent(accountId);
|
const proxyAgent = await this._getProxyAgent(accountId);
|
||||||
|
|
||||||
// 发送流式请求并捕获usage数据
|
// 发送流式请求并捕获usage数据
|
||||||
return await this._makeClaudeStreamRequestWithUsageCapture(processedBody, accessToken, proxyAgent, clientHeaders, responseStream, usageCallback, accountId, sessionHash, streamTransformer, options);
|
return await this._makeClaudeStreamRequestWithUsageCapture(processedBody, accessToken, proxyAgent, clientHeaders, responseStream, (usageData) => {
|
||||||
|
// 在usageCallback中添加accountId
|
||||||
|
usageCallback({ ...usageData, accountId });
|
||||||
|
}, accountId, sessionHash, streamTransformer, options);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ Claude stream relay with usage capture failed:', error);
|
logger.error('❌ Claude stream relay with usage capture failed:', error);
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
150
web/admin/app.js
150
web/admin/app.js
@@ -112,6 +112,8 @@ const app = createApp({
|
|||||||
apiKeys: [],
|
apiKeys: [],
|
||||||
apiKeysLoading: false,
|
apiKeysLoading: false,
|
||||||
apiKeyStatsTimeRange: 'all', // API Key统计时间范围:all, 7days, monthly
|
apiKeyStatsTimeRange: 'all', // API Key统计时间范围:all, 7days, monthly
|
||||||
|
apiKeysSortBy: '', // 当前排序字段
|
||||||
|
apiKeysSortOrder: 'asc', // 排序顺序 'asc' 或 'desc'
|
||||||
showCreateApiKeyModal: false,
|
showCreateApiKeyModal: false,
|
||||||
createApiKeyLoading: false,
|
createApiKeyLoading: false,
|
||||||
apiKeyForm: {
|
apiKeyForm: {
|
||||||
@@ -199,6 +201,8 @@ const app = createApp({
|
|||||||
// 账户
|
// 账户
|
||||||
accounts: [],
|
accounts: [],
|
||||||
accountsLoading: false,
|
accountsLoading: false,
|
||||||
|
accountSortBy: 'dailyTokens', // 默认按今日Token排序
|
||||||
|
accountsSortOrder: 'asc', // 排序顺序 'asc' 或 'desc'
|
||||||
showCreateAccountModal: false,
|
showCreateAccountModal: false,
|
||||||
createAccountLoading: false,
|
createAccountLoading: false,
|
||||||
accountForm: {
|
accountForm: {
|
||||||
@@ -302,6 +306,83 @@ const app = createApp({
|
|||||||
return `${window.location.protocol}//${window.location.host}/api/`;
|
return `${window.location.protocol}//${window.location.host}/api/`;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 排序后的账户列表
|
||||||
|
sortedAccounts() {
|
||||||
|
if (!this.accountsSortBy) {
|
||||||
|
return this.accounts;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...this.accounts].sort((a, b) => {
|
||||||
|
let aValue = a[this.accountsSortBy];
|
||||||
|
let bValue = b[this.accountsSortBy];
|
||||||
|
|
||||||
|
// 特殊处理状态字段
|
||||||
|
if (this.accountsSortBy === 'status') {
|
||||||
|
aValue = a.isActive ? 1 : 0;
|
||||||
|
bValue = b.isActive ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理字符串比较
|
||||||
|
if (typeof aValue === 'string' && typeof bValue === 'string') {
|
||||||
|
aValue = aValue.toLowerCase();
|
||||||
|
bValue = bValue.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 排序
|
||||||
|
if (this.accountsSortOrder === 'asc') {
|
||||||
|
return aValue > bValue ? 1 : aValue < bValue ? -1 : 0;
|
||||||
|
} else {
|
||||||
|
return aValue < bValue ? 1 : aValue > bValue ? -1 : 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// 排序后的API Keys列表
|
||||||
|
sortedApiKeys() {
|
||||||
|
if (!this.apiKeysSortBy) {
|
||||||
|
return this.apiKeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...this.apiKeys].sort((a, b) => {
|
||||||
|
let aValue, bValue;
|
||||||
|
|
||||||
|
// 特殊处理不同字段
|
||||||
|
switch (this.apiKeysSortBy) {
|
||||||
|
case 'status':
|
||||||
|
aValue = a.isActive ? 1 : 0;
|
||||||
|
bValue = b.isActive ? 1 : 0;
|
||||||
|
break;
|
||||||
|
case 'cost':
|
||||||
|
// 计算费用,转换为数字比较
|
||||||
|
aValue = this.calculateApiKeyCostNumber(a.usage);
|
||||||
|
bValue = this.calculateApiKeyCostNumber(b.usage);
|
||||||
|
break;
|
||||||
|
case 'createdAt':
|
||||||
|
case 'expiresAt':
|
||||||
|
// 日期比较
|
||||||
|
aValue = a[this.apiKeysSortBy] ? new Date(a[this.apiKeysSortBy]).getTime() : 0;
|
||||||
|
bValue = b[this.apiKeysSortBy] ? new Date(b[this.apiKeysSortBy]).getTime() : 0;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
aValue = a[this.apiKeysSortBy];
|
||||||
|
bValue = b[this.apiKeysSortBy];
|
||||||
|
|
||||||
|
// 处理字符串比较
|
||||||
|
if (typeof aValue === 'string' && typeof bValue === 'string') {
|
||||||
|
aValue = aValue.toLowerCase();
|
||||||
|
bValue = bValue.toLowerCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 排序
|
||||||
|
if (this.apiKeysSortOrder === 'asc') {
|
||||||
|
return aValue > bValue ? 1 : aValue < bValue ? -1 : 0;
|
||||||
|
} else {
|
||||||
|
return aValue < bValue ? 1 : aValue > bValue ? -1 : 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
// 获取专属账号列表
|
// 获取专属账号列表
|
||||||
dedicatedAccounts() {
|
dedicatedAccounts() {
|
||||||
return this.accounts.filter(account =>
|
return this.accounts.filter(account =>
|
||||||
@@ -407,6 +488,30 @@ const app = createApp({
|
|||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
|
// 账户列表排序
|
||||||
|
sortAccounts(field) {
|
||||||
|
if (this.accountsSortBy === field) {
|
||||||
|
// 如果点击的是当前排序字段,切换排序顺序
|
||||||
|
this.accountsSortOrder = this.accountsSortOrder === 'asc' ? 'desc' : 'asc';
|
||||||
|
} else {
|
||||||
|
// 如果点击的是新字段,设置为升序
|
||||||
|
this.accountsSortBy = field;
|
||||||
|
this.accountsSortOrder = 'asc';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// API Keys列表排序
|
||||||
|
sortApiKeys(field) {
|
||||||
|
if (this.apiKeysSortBy === field) {
|
||||||
|
// 如果点击的是当前排序字段,切换排序顺序
|
||||||
|
this.apiKeysSortOrder = this.apiKeysSortOrder === 'asc' ? 'desc' : 'asc';
|
||||||
|
} else {
|
||||||
|
// 如果点击的是新字段,设置为升序
|
||||||
|
this.apiKeysSortBy = field;
|
||||||
|
this.apiKeysSortOrder = 'asc';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// 从URL读取tab参数并设置activeTab
|
// 从URL读取tab参数并设置activeTab
|
||||||
initializeTabFromUrl() {
|
initializeTabFromUrl() {
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
@@ -1888,6 +1993,9 @@ const app = createApp({
|
|||||||
account.boundApiKeysCount = this.apiKeys.filter(key => key.geminiAccountId === account.id).length;
|
account.boundApiKeysCount = this.apiKeys.filter(key => key.geminiAccountId === account.id).length;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 加载完成后自动排序
|
||||||
|
this.sortAccounts();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load accounts:', error);
|
console.error('Failed to load accounts:', error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1895,6 +2003,35 @@ const app = createApp({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 账户排序
|
||||||
|
sortAccounts() {
|
||||||
|
if (!this.accounts || this.accounts.length === 0) return;
|
||||||
|
|
||||||
|
this.accounts.sort((a, b) => {
|
||||||
|
switch (this.accountSortBy) {
|
||||||
|
case 'name':
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
case 'dailyTokens':
|
||||||
|
const aTokens = (a.usage && a.usage.daily && a.usage.daily.allTokens) || 0;
|
||||||
|
const bTokens = (b.usage && b.usage.daily && b.usage.daily.allTokens) || 0;
|
||||||
|
return bTokens - aTokens; // 降序
|
||||||
|
case 'dailyRequests':
|
||||||
|
const aRequests = (a.usage && a.usage.daily && a.usage.daily.requests) || 0;
|
||||||
|
const bRequests = (b.usage && b.usage.daily && b.usage.daily.requests) || 0;
|
||||||
|
return bRequests - aRequests; // 降序
|
||||||
|
case 'totalTokens':
|
||||||
|
const aTotalTokens = (a.usage && a.usage.total && a.usage.total.allTokens) || 0;
|
||||||
|
const bTotalTokens = (b.usage && b.usage.total && b.usage.total.allTokens) || 0;
|
||||||
|
return bTotalTokens - aTotalTokens; // 降序
|
||||||
|
case 'lastUsed':
|
||||||
|
const aLastUsed = a.lastUsedAt ? new Date(a.lastUsedAt) : new Date(0);
|
||||||
|
const bLastUsed = b.lastUsedAt ? new Date(b.lastUsedAt) : new Date(0);
|
||||||
|
return bLastUsed - aLastUsed; // 降序(最近使用的在前)
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
async loadModelStats() {
|
async loadModelStats() {
|
||||||
this.modelStatsLoading = true;
|
this.modelStatsLoading = true;
|
||||||
@@ -3181,6 +3318,19 @@ const app = createApp({
|
|||||||
return '$0.000000';
|
return '$0.000000';
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 计算API Key费用数值(用于排序)
|
||||||
|
calculateApiKeyCostNumber(usage) {
|
||||||
|
if (!usage || !usage.total) return 0;
|
||||||
|
|
||||||
|
// 使用后端返回的准确费用数据
|
||||||
|
if (usage.total.cost) {
|
||||||
|
return usage.total.cost;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有后端费用数据,返回0
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
|
||||||
// 初始化日期筛选器
|
// 初始化日期筛选器
|
||||||
initializeDateFilter() {
|
initializeDateFilter() {
|
||||||
console.log('Initializing date filter, default preset:', this.dateFilter.preset);
|
console.log('Initializing date filter, default preset:', this.dateFilter.preset);
|
||||||
|
|||||||
@@ -575,17 +575,40 @@
|
|||||||
<table class="min-w-full">
|
<table class="min-w-full">
|
||||||
<thead class="bg-gray-50/80 backdrop-blur-sm">
|
<thead class="bg-gray-50/80 backdrop-blur-sm">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">名称</th>
|
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100" @click="sortApiKeys('name')">
|
||||||
|
名称
|
||||||
|
<i v-if="apiKeysSortBy === 'name'" :class="['fas', apiKeysSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"></i>
|
||||||
|
<i v-else class="fas fa-sort ml-1 text-gray-400"></i>
|
||||||
|
</th>
|
||||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">API Key</th>
|
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">API Key</th>
|
||||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">状态</th>
|
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100" @click="sortApiKeys('status')">
|
||||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">使用统计</th>
|
状态
|
||||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">创建时间</th>
|
<i v-if="apiKeysSortBy === 'status'" :class="['fas', apiKeysSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"></i>
|
||||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">过期时间</th>
|
<i v-else class="fas fa-sort ml-1 text-gray-400"></i>
|
||||||
|
</th>
|
||||||
|
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
|
||||||
|
使用统计
|
||||||
|
<span class="cursor-pointer hover:bg-gray-100 px-2 py-1 rounded" @click="sortApiKeys('cost')">
|
||||||
|
(费用
|
||||||
|
<i v-if="apiKeysSortBy === 'cost'" :class="['fas', apiKeysSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"></i>
|
||||||
|
<i v-else class="fas fa-sort ml-1 text-gray-400"></i>)
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100" @click="sortApiKeys('createdAt')">
|
||||||
|
创建时间
|
||||||
|
<i v-if="apiKeysSortBy === 'createdAt'" :class="['fas', apiKeysSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"></i>
|
||||||
|
<i v-else class="fas fa-sort ml-1 text-gray-400"></i>
|
||||||
|
</th>
|
||||||
|
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100" @click="sortApiKeys('expiresAt')">
|
||||||
|
过期时间
|
||||||
|
<i v-if="apiKeysSortBy === 'expiresAt'" :class="['fas', apiKeysSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"></i>
|
||||||
|
<i v-else class="fas fa-sort ml-1 text-gray-400"></i>
|
||||||
|
</th>
|
||||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">操作</th>
|
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-200/50">
|
<tbody class="divide-y divide-gray-200/50">
|
||||||
<template v-for="key in apiKeys" :key="key.id">
|
<template v-for="key in sortedApiKeys" :key="key.id">
|
||||||
<!-- API Key 主行 -->
|
<!-- API Key 主行 -->
|
||||||
<tr class="table-row">
|
<tr class="table-row">
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
@@ -922,12 +945,21 @@
|
|||||||
<h3 class="text-xl font-bold text-gray-900 mb-2">账户管理</h3>
|
<h3 class="text-xl font-bold text-gray-900 mb-2">账户管理</h3>
|
||||||
<p class="text-gray-600">管理您的 Claude 和 Gemini 账户及代理配置</p>
|
<p class="text-gray-600">管理您的 Claude 和 Gemini 账户及代理配置</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div class="flex gap-2">
|
||||||
@click.stop="openCreateAccountModal"
|
<select v-model="accountSortBy" @change="sortAccounts()" class="form-input px-3 py-2 text-sm">
|
||||||
class="btn btn-success px-6 py-3 flex items-center gap-2"
|
<option value="name">按名称排序</option>
|
||||||
>
|
<option value="dailyTokens">按今日Token排序</option>
|
||||||
<i class="fas fa-plus"></i>添加账户
|
<option value="dailyRequests">按今日请求数排序</option>
|
||||||
</button>
|
<option value="totalTokens">按总Token排序</option>
|
||||||
|
<option value="lastUsed">按最后使用排序</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
@click.stop="openCreateAccountModal"
|
||||||
|
class="btn btn-success px-6 py-3 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<i class="fas fa-plus"></i>添加账户
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="accountsLoading" class="text-center py-12">
|
<div v-if="accountsLoading" class="text-center py-12">
|
||||||
@@ -947,17 +979,34 @@
|
|||||||
<table class="min-w-full">
|
<table class="min-w-full">
|
||||||
<thead class="bg-gray-50/80 backdrop-blur-sm">
|
<thead class="bg-gray-50/80 backdrop-blur-sm">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">名称</th>
|
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100" @click="sortAccounts('name')">
|
||||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">平台</th>
|
名称
|
||||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">类型</th>
|
<i v-if="accountsSortBy === 'name'" :class="['fas', accountsSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"></i>
|
||||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">状态</th>
|
<i v-else class="fas fa-sort ml-1 text-gray-400"></i>
|
||||||
|
</th>
|
||||||
|
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100" @click="sortAccounts('platform')">
|
||||||
|
平台
|
||||||
|
<i v-if="accountsSortBy === 'platform'" :class="['fas', accountsSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"></i>
|
||||||
|
<i v-else class="fas fa-sort ml-1 text-gray-400"></i>
|
||||||
|
</th>
|
||||||
|
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100" @click="sortAccounts('accountType')">
|
||||||
|
类型
|
||||||
|
<i v-if="accountsSortBy === 'accountType'" :class="['fas', accountsSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"></i>
|
||||||
|
<i v-else class="fas fa-sort ml-1 text-gray-400"></i>
|
||||||
|
</th>
|
||||||
|
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100" @click="sortAccounts('status')">
|
||||||
|
状态
|
||||||
|
<i v-if="accountsSortBy === 'status'" :class="['fas', accountsSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"></i>
|
||||||
|
<i v-else class="fas fa-sort ml-1 text-gray-400"></i>
|
||||||
|
</th>
|
||||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">代理</th>
|
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">代理</th>
|
||||||
|
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">今日使用</th>
|
||||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">最后使用</th>
|
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">最后使用</th>
|
||||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">操作</th>
|
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-200/50">
|
<tbody class="divide-y divide-gray-200/50">
|
||||||
<tr v-for="account in accounts" :key="account.id" class="table-row">
|
<tr v-for="account in sortedAccounts" :key="account.id" class="table-row">
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div class="w-8 h-8 bg-gradient-to-br from-green-500 to-green-600 rounded-lg flex items-center justify-center mr-3">
|
<div class="w-8 h-8 bg-gradient-to-br from-green-500 to-green-600 rounded-lg flex items-center justify-center mr-3">
|
||||||
@@ -1024,6 +1073,22 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-else class="text-gray-400">无代理</div>
|
<div v-else class="text-gray-400">无代理</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||||
|
<div v-if="account.usage && account.usage.daily" class="space-y-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||||
|
<span class="text-sm font-medium text-gray-900">{{ account.usage.daily.requests || 0 }} 次</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="w-2 h-2 bg-blue-500 rounded-full"></div>
|
||||||
|
<span class="text-xs text-gray-600">{{ formatNumber(account.usage.daily.allTokens || 0) }} tokens</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="account.usage.averages && account.usage.averages.rpm > 0" class="text-xs text-gray-500">
|
||||||
|
平均 {{ account.usage.averages.rpm.toFixed(2) }} RPM
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-gray-400 text-xs">暂无数据</div>
|
||||||
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
{{ account.lastUsedAt ? new Date(account.lastUsedAt).toLocaleDateString() : '从未使用' }}
|
{{ account.lastUsedAt ? new Date(account.lastUsedAt).toLocaleDateString() : '从未使用' }}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
Reference in New Issue
Block a user