mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 09:38:02 +00:00
Merge branch 'pr-503' into dev
This commit is contained in:
@@ -2103,6 +2103,61 @@ router.get('/claude-accounts', authenticateAdmin, async (req, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 批量获取 Claude 账户的 OAuth Usage 数据
|
||||||
|
router.get('/claude-accounts/usage', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const accounts = await redis.getAllClaudeAccounts()
|
||||||
|
|
||||||
|
// 批量并发获取所有活跃 OAuth 账户的 Usage
|
||||||
|
const usagePromises = accounts.map(async (account) => {
|
||||||
|
// 检查是否为 OAuth 账户:scopes 包含 OAuth 相关权限
|
||||||
|
const scopes = account.scopes && account.scopes.trim() ? account.scopes.split(' ') : []
|
||||||
|
const isOAuth = scopes.includes('user:profile') && scopes.includes('user:inference')
|
||||||
|
|
||||||
|
// 仅为 OAuth 授权的活跃账户调用 usage API
|
||||||
|
if (
|
||||||
|
isOAuth &&
|
||||||
|
account.isActive === 'true' &&
|
||||||
|
account.accessToken &&
|
||||||
|
account.status === 'active'
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const usageData = await claudeAccountService.fetchOAuthUsage(account.id)
|
||||||
|
if (usageData) {
|
||||||
|
await claudeAccountService.updateClaudeUsageSnapshot(account.id, usageData)
|
||||||
|
}
|
||||||
|
// 重新读取更新后的数据
|
||||||
|
const updatedAccount = await redis.getClaudeAccount(account.id)
|
||||||
|
return {
|
||||||
|
accountId: account.id,
|
||||||
|
claudeUsage: claudeAccountService.buildClaudeUsageSnapshot(updatedAccount)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug(`Failed to fetch OAuth usage for ${account.id}:`, error.message)
|
||||||
|
return { accountId: account.id, claudeUsage: null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Setup Token 账户不调用 usage API,直接返回 null
|
||||||
|
return { accountId: account.id, claudeUsage: null }
|
||||||
|
})
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(usagePromises)
|
||||||
|
|
||||||
|
// 转换为 { accountId: usage } 映射
|
||||||
|
const usageMap = {}
|
||||||
|
results.forEach((result) => {
|
||||||
|
if (result.status === 'fulfilled' && result.value) {
|
||||||
|
usageMap[result.value.accountId] = result.value.claudeUsage
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
res.json({ success: true, data: usageMap })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to fetch Claude accounts usage:', error)
|
||||||
|
res.status(500).json({ error: 'Failed to fetch usage data', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// 创建新的Claude账户
|
// 创建新的Claude账户
|
||||||
router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
|
router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -451,6 +451,14 @@ class ClaudeAccountService {
|
|||||||
// 获取会话窗口信息
|
// 获取会话窗口信息
|
||||||
const sessionWindowInfo = await this.getSessionWindowInfo(account.id)
|
const sessionWindowInfo = await this.getSessionWindowInfo(account.id)
|
||||||
|
|
||||||
|
// 构建 Claude Usage 快照(从 Redis 读取)
|
||||||
|
const claudeUsage = this.buildClaudeUsageSnapshot(account)
|
||||||
|
|
||||||
|
// 判断授权类型:检查 scopes 是否包含 OAuth 相关权限
|
||||||
|
const scopes = account.scopes && account.scopes.trim() ? account.scopes.split(' ') : []
|
||||||
|
const isOAuth = scopes.includes('user:profile') && scopes.includes('user:inference')
|
||||||
|
const authType = isOAuth ? 'oauth' : 'setup-token'
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: account.id,
|
id: account.id,
|
||||||
name: account.name,
|
name: account.name,
|
||||||
@@ -463,6 +471,7 @@ class ClaudeAccountService {
|
|||||||
accountType: account.accountType || 'shared', // 兼容旧数据,默认为共享
|
accountType: account.accountType || 'shared', // 兼容旧数据,默认为共享
|
||||||
priority: parseInt(account.priority) || 50, // 兼容旧数据,默认优先级50
|
priority: parseInt(account.priority) || 50, // 兼容旧数据,默认优先级50
|
||||||
platform: account.platform || 'claude', // 添加平台标识,用于前端区分
|
platform: account.platform || 'claude', // 添加平台标识,用于前端区分
|
||||||
|
authType, // OAuth 或 Setup Token
|
||||||
createdAt: account.createdAt,
|
createdAt: account.createdAt,
|
||||||
lastUsedAt: account.lastUsedAt,
|
lastUsedAt: account.lastUsedAt,
|
||||||
lastRefreshAt: account.lastRefreshAt,
|
lastRefreshAt: account.lastRefreshAt,
|
||||||
@@ -493,6 +502,8 @@ class ClaudeAccountService {
|
|||||||
remainingTime: null,
|
remainingTime: null,
|
||||||
lastRequestTime: null
|
lastRequestTime: null
|
||||||
},
|
},
|
||||||
|
// 添加 Claude Usage 信息(三窗口)
|
||||||
|
claudeUsage: claudeUsage || null,
|
||||||
// 添加调度状态
|
// 添加调度状态
|
||||||
schedulable: account.schedulable !== 'false', // 默认为true,兼容历史数据
|
schedulable: account.schedulable !== 'false', // 默认为true,兼容历史数据
|
||||||
// 添加自动停止调度设置
|
// 添加自动停止调度设置
|
||||||
@@ -1131,6 +1142,16 @@ class ClaudeAccountService {
|
|||||||
return `${maskedUsername}@${domain}`
|
return `${maskedUsername}@${domain}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔢 安全转换为数字或null
|
||||||
|
_toNumberOrNull(value) {
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const num = Number(value)
|
||||||
|
return Number.isFinite(num) ? num : null
|
||||||
|
}
|
||||||
|
|
||||||
// 🧹 清理错误账户
|
// 🧹 清理错误账户
|
||||||
async cleanupErrorAccounts() {
|
async cleanupErrorAccounts() {
|
||||||
try {
|
try {
|
||||||
@@ -1578,6 +1599,173 @@ class ClaudeAccountService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 📊 获取 OAuth Usage 数据
|
||||||
|
async fetchOAuthUsage(accountId, accessToken = null, agent = null) {
|
||||||
|
try {
|
||||||
|
const accountData = await redis.getClaudeAccount(accountId)
|
||||||
|
if (!accountData || Object.keys(accountData).length === 0) {
|
||||||
|
throw new Error('Account not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有提供 accessToken,使用 getValidAccessToken 自动检查过期并刷新
|
||||||
|
if (!accessToken) {
|
||||||
|
accessToken = await this.getValidAccessToken(accountId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有提供 agent,创建代理
|
||||||
|
if (!agent) {
|
||||||
|
agent = this._createProxyAgent(accountData.proxy)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`📊 Fetching OAuth usage for account: ${accountData.name} (${accountId})`)
|
||||||
|
|
||||||
|
// 请求 OAuth usage 接口
|
||||||
|
const response = await axios.get('https://api.anthropic.com/api/oauth/usage', {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
'anthropic-beta': 'oauth-2025-04-20',
|
||||||
|
'User-Agent': 'claude-cli/1.0.56 (external, cli)',
|
||||||
|
'Accept-Language': 'en-US,en;q=0.9'
|
||||||
|
},
|
||||||
|
httpsAgent: agent,
|
||||||
|
timeout: 15000
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status === 200 && response.data) {
|
||||||
|
logger.debug('✅ Successfully fetched OAuth usage data:', {
|
||||||
|
accountId,
|
||||||
|
fiveHour: response.data.five_hour?.utilization,
|
||||||
|
sevenDay: response.data.seven_day?.utilization,
|
||||||
|
sevenDayOpus: response.data.seven_day_opus?.utilization
|
||||||
|
})
|
||||||
|
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn(`⚠️ Failed to fetch OAuth usage for account ${accountId}: ${response.status}`)
|
||||||
|
return null
|
||||||
|
} catch (error) {
|
||||||
|
// 403 错误通常表示使用的是 Setup Token 而非 OAuth
|
||||||
|
if (error.response?.status === 403) {
|
||||||
|
logger.debug(
|
||||||
|
`⚠️ OAuth usage API returned 403 for account ${accountId}. This account likely uses Setup Token instead of OAuth.`
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他错误正常记录
|
||||||
|
logger.error(
|
||||||
|
`❌ Failed to fetch OAuth usage for account ${accountId}:`,
|
||||||
|
error.response?.data || error.message
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 📊 构建 Claude Usage 快照(从 Redis 数据)
|
||||||
|
buildClaudeUsageSnapshot(accountData) {
|
||||||
|
const updatedAt = accountData.claudeUsageUpdatedAt
|
||||||
|
|
||||||
|
const fiveHourUtilization = this._toNumberOrNull(accountData.claudeFiveHourUtilization)
|
||||||
|
const fiveHourResetsAt = accountData.claudeFiveHourResetsAt
|
||||||
|
const sevenDayUtilization = this._toNumberOrNull(accountData.claudeSevenDayUtilization)
|
||||||
|
const sevenDayResetsAt = accountData.claudeSevenDayResetsAt
|
||||||
|
const sevenDayOpusUtilization = this._toNumberOrNull(accountData.claudeSevenDayOpusUtilization)
|
||||||
|
const sevenDayOpusResetsAt = accountData.claudeSevenDayOpusResetsAt
|
||||||
|
|
||||||
|
const hasFiveHourData = fiveHourUtilization !== null || fiveHourResetsAt
|
||||||
|
const hasSevenDayData = sevenDayUtilization !== null || sevenDayResetsAt
|
||||||
|
const hasSevenDayOpusData = sevenDayOpusUtilization !== null || sevenDayOpusResetsAt
|
||||||
|
|
||||||
|
if (!updatedAt && !hasFiveHourData && !hasSevenDayData && !hasSevenDayOpusData) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
return {
|
||||||
|
updatedAt,
|
||||||
|
fiveHour: {
|
||||||
|
utilization: fiveHourUtilization,
|
||||||
|
resetsAt: fiveHourResetsAt,
|
||||||
|
remainingSeconds: fiveHourResetsAt
|
||||||
|
? Math.max(0, Math.floor((new Date(fiveHourResetsAt).getTime() - now) / 1000))
|
||||||
|
: null
|
||||||
|
},
|
||||||
|
sevenDay: {
|
||||||
|
utilization: sevenDayUtilization,
|
||||||
|
resetsAt: sevenDayResetsAt,
|
||||||
|
remainingSeconds: sevenDayResetsAt
|
||||||
|
? Math.max(0, Math.floor((new Date(sevenDayResetsAt).getTime() - now) / 1000))
|
||||||
|
: null
|
||||||
|
},
|
||||||
|
sevenDayOpus: {
|
||||||
|
utilization: sevenDayOpusUtilization,
|
||||||
|
resetsAt: sevenDayOpusResetsAt,
|
||||||
|
remainingSeconds: sevenDayOpusResetsAt
|
||||||
|
? Math.max(0, Math.floor((new Date(sevenDayOpusResetsAt).getTime() - now) / 1000))
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 📊 更新 Claude Usage 快照到 Redis
|
||||||
|
async updateClaudeUsageSnapshot(accountId, usageData) {
|
||||||
|
if (!usageData || typeof usageData !== 'object') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates = {}
|
||||||
|
|
||||||
|
// 5小时窗口
|
||||||
|
if (usageData.five_hour) {
|
||||||
|
if (usageData.five_hour.utilization !== undefined) {
|
||||||
|
updates.claudeFiveHourUtilization = String(usageData.five_hour.utilization)
|
||||||
|
}
|
||||||
|
if (usageData.five_hour.resets_at) {
|
||||||
|
updates.claudeFiveHourResetsAt = usageData.five_hour.resets_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7天窗口
|
||||||
|
if (usageData.seven_day) {
|
||||||
|
if (usageData.seven_day.utilization !== undefined) {
|
||||||
|
updates.claudeSevenDayUtilization = String(usageData.seven_day.utilization)
|
||||||
|
}
|
||||||
|
if (usageData.seven_day.resets_at) {
|
||||||
|
updates.claudeSevenDayResetsAt = usageData.seven_day.resets_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7天Opus窗口
|
||||||
|
if (usageData.seven_day_opus) {
|
||||||
|
if (usageData.seven_day_opus.utilization !== undefined) {
|
||||||
|
updates.claudeSevenDayOpusUtilization = String(usageData.seven_day_opus.utilization)
|
||||||
|
}
|
||||||
|
if (usageData.seven_day_opus.resets_at) {
|
||||||
|
updates.claudeSevenDayOpusResetsAt = usageData.seven_day_opus.resets_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(updates).length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updates.claudeUsageUpdatedAt = new Date().toISOString()
|
||||||
|
|
||||||
|
const accountData = await redis.getClaudeAccount(accountId)
|
||||||
|
if (accountData && Object.keys(accountData).length > 0) {
|
||||||
|
Object.assign(accountData, updates)
|
||||||
|
await redis.setClaudeAccount(accountId, accountData)
|
||||||
|
logger.debug(
|
||||||
|
`📊 Updated Claude usage snapshot for account ${accountId}:`,
|
||||||
|
Object.keys(updates)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 📊 获取账号 Profile 信息并更新账号类型
|
// 📊 获取账号 Profile 信息并更新账号类型
|
||||||
async fetchAndUpdateAccountProfile(accountId, accessToken = null, agent = null) {
|
async fetchAndUpdateAccountProfile(accountId, accessToken = null, agent = null) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -321,6 +321,43 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="h-px bg-gray-200 dark:bg-gray-600/50"></div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="text-sm font-semibold text-white dark:text-gray-900">
|
||||||
|
Claude OAuth 账户
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-200 dark:text-gray-600">
|
||||||
|
展示三个窗口的使用率(utilization百分比),颜色含义同上。
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1 text-gray-200 dark:text-gray-600">
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<i class="fas fa-clock mt-[2px] text-[10px] text-indigo-500"></i>
|
||||||
|
<span class="font-medium text-white dark:text-gray-900"
|
||||||
|
>5h 窗口:5小时滑动窗口的使用率。</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<i
|
||||||
|
class="fas fa-calendar-alt mt-[2px] text-[10px] text-emerald-500"
|
||||||
|
></i>
|
||||||
|
<span class="font-medium text-white dark:text-gray-900"
|
||||||
|
>7d 窗口:7天总限额的使用率。</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<i class="fas fa-gem mt-[2px] text-[10px] text-purple-500"></i>
|
||||||
|
<span class="font-medium text-white dark:text-gray-900"
|
||||||
|
>Opus 窗口:7天Opus模型专用限额。</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<i class="fas fa-sync-alt mt-[2px] text-[10px] text-blue-500"></i>
|
||||||
|
<span class="font-medium text-white dark:text-gray-900"
|
||||||
|
>到达重置时间后自动归零。</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<i
|
<i
|
||||||
@@ -667,69 +704,177 @@
|
|||||||
<div v-else class="text-xs text-gray-400">暂无数据</div>
|
<div v-else class="text-xs text-gray-400">暂无数据</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="whitespace-nowrap px-3 py-4">
|
<td class="whitespace-nowrap px-3 py-4">
|
||||||
<div
|
<div v-if="account.platform === 'claude'" class="space-y-2">
|
||||||
v-if="
|
<!-- OAuth 账户:显示三窗口 OAuth usage -->
|
||||||
account.platform === 'claude' &&
|
<div v-if="isClaudeOAuth(account) && account.claudeUsage" class="space-y-2">
|
||||||
account.sessionWindow &&
|
<!-- 5小时窗口 -->
|
||||||
account.sessionWindow.hasActiveWindow
|
<div class="rounded-lg bg-gray-50 p-2 dark:bg-gray-700/70">
|
||||||
"
|
<div class="flex items-center gap-2">
|
||||||
class="space-y-2"
|
<span
|
||||||
>
|
class="inline-flex min-w-[32px] justify-center rounded-full bg-indigo-100 px-2 py-0.5 text-[11px] font-medium text-indigo-600 dark:bg-indigo-500/20 dark:text-indigo-300"
|
||||||
<!-- 使用统计在顶部 -->
|
>
|
||||||
|
5h
|
||||||
|
</span>
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="h-2 flex-1 rounded-full bg-gray-200 dark:bg-gray-600">
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'h-2 rounded-full transition-all duration-300',
|
||||||
|
getClaudeUsageBarClass(account.claudeUsage.fiveHour)
|
||||||
|
]"
|
||||||
|
:style="{
|
||||||
|
width: getClaudeUsageWidth(account.claudeUsage.fiveHour)
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="w-12 text-right text-xs font-semibold text-gray-800 dark:text-gray-100"
|
||||||
|
>
|
||||||
|
{{ formatClaudeUsagePercent(account.claudeUsage.fiveHour) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
|
||||||
|
重置剩余 {{ formatClaudeRemaining(account.claudeUsage.fiveHour) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 7天窗口 -->
|
||||||
|
<div class="rounded-lg bg-gray-50 p-2 dark:bg-gray-700/70">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="inline-flex min-w-[32px] justify-center rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-medium text-emerald-600 dark:bg-emerald-500/20 dark:text-emerald-300"
|
||||||
|
>
|
||||||
|
7d
|
||||||
|
</span>
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="h-2 flex-1 rounded-full bg-gray-200 dark:bg-gray-600">
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'h-2 rounded-full transition-all duration-300',
|
||||||
|
getClaudeUsageBarClass(account.claudeUsage.sevenDay)
|
||||||
|
]"
|
||||||
|
:style="{
|
||||||
|
width: getClaudeUsageWidth(account.claudeUsage.sevenDay)
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="w-12 text-right text-xs font-semibold text-gray-800 dark:text-gray-100"
|
||||||
|
>
|
||||||
|
{{ formatClaudeUsagePercent(account.claudeUsage.sevenDay) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
|
||||||
|
重置剩余 {{ formatClaudeRemaining(account.claudeUsage.sevenDay) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 7天Opus窗口 -->
|
||||||
|
<div class="rounded-lg bg-gray-50 p-2 dark:bg-gray-700/70">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="inline-flex min-w-[32px] justify-center rounded-full bg-purple-100 px-2 py-0.5 text-[11px] font-medium text-purple-600 dark:bg-purple-500/20 dark:text-purple-300"
|
||||||
|
>
|
||||||
|
Opus
|
||||||
|
</span>
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="h-2 flex-1 rounded-full bg-gray-200 dark:bg-gray-600">
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'h-2 rounded-full transition-all duration-300',
|
||||||
|
getClaudeUsageBarClass(account.claudeUsage.sevenDayOpus)
|
||||||
|
]"
|
||||||
|
:style="{
|
||||||
|
width: getClaudeUsageWidth(account.claudeUsage.sevenDayOpus)
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="w-12 text-right text-xs font-semibold text-gray-800 dark:text-gray-100"
|
||||||
|
>
|
||||||
|
{{ formatClaudeUsagePercent(account.claudeUsage.sevenDayOpus) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
|
||||||
|
重置剩余 {{ formatClaudeRemaining(account.claudeUsage.sevenDayOpus) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Setup Token 账户:显示原有的会话窗口时间进度 -->
|
||||||
<div
|
<div
|
||||||
v-if="account.usage && account.usage.sessionWindow"
|
v-else-if="
|
||||||
class="flex items-center gap-3 text-xs"
|
!isClaudeOAuth(account) &&
|
||||||
|
account.sessionWindow &&
|
||||||
|
account.sessionWindow.hasActiveWindow
|
||||||
|
"
|
||||||
|
class="space-y-2"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-1">
|
<!-- 使用统计在顶部 -->
|
||||||
<div class="h-1.5 w-1.5 rounded-full bg-purple-500" />
|
|
||||||
<span class="font-medium text-gray-900 dark:text-gray-100">
|
|
||||||
{{ formatNumber(account.usage.sessionWindow.totalTokens) }}M
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<div class="h-1.5 w-1.5 rounded-full bg-green-500" />
|
|
||||||
<span class="font-medium text-gray-900 dark:text-gray-100">
|
|
||||||
${{ formatCost(account.usage.sessionWindow.totalCost) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 进度条 -->
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<div class="h-2 w-24 rounded-full bg-gray-200 dark:bg-gray-700">
|
|
||||||
<div
|
|
||||||
:class="[
|
|
||||||
'h-2 rounded-full transition-all duration-300',
|
|
||||||
getSessionProgressBarClass(
|
|
||||||
account.sessionWindow.sessionWindowStatus,
|
|
||||||
account
|
|
||||||
)
|
|
||||||
]"
|
|
||||||
:style="{ width: account.sessionWindow.progress + '%' }"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span class="min-w-[32px] text-xs font-medium text-gray-700 dark:text-gray-200">
|
|
||||||
{{ account.sessionWindow.progress }}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 时间信息 -->
|
|
||||||
<div class="text-xs text-gray-600 dark:text-gray-400">
|
|
||||||
<div>
|
|
||||||
{{
|
|
||||||
formatSessionWindow(
|
|
||||||
account.sessionWindow.windowStart,
|
|
||||||
account.sessionWindow.windowEnd
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
v-if="account.sessionWindow.remainingTime > 0"
|
v-if="account.usage && account.usage.sessionWindow"
|
||||||
class="font-medium text-indigo-600 dark:text-indigo-400"
|
class="flex items-center gap-3 text-xs"
|
||||||
>
|
>
|
||||||
剩余 {{ formatRemainingTime(account.sessionWindow.remainingTime) }}
|
<div class="flex items-center gap-1">
|
||||||
|
<div class="h-1.5 w-1.5 rounded-full bg-purple-500" />
|
||||||
|
<span class="font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{{ formatNumber(account.usage.sessionWindow.totalTokens) }}M
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<div class="h-1.5 w-1.5 rounded-full bg-green-500" />
|
||||||
|
<span class="font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
${{ formatCost(account.usage.sessionWindow.totalCost) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 进度条 -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="h-2 w-24 rounded-full bg-gray-200 dark:bg-gray-700">
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'h-2 rounded-full transition-all duration-300',
|
||||||
|
getSessionProgressBarClass(
|
||||||
|
account.sessionWindow.sessionWindowStatus,
|
||||||
|
account
|
||||||
|
)
|
||||||
|
]"
|
||||||
|
:style="{ width: account.sessionWindow.progress + '%' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="min-w-[32px] text-xs font-medium text-gray-700 dark:text-gray-200"
|
||||||
|
>
|
||||||
|
{{ account.sessionWindow.progress }}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 时间信息 -->
|
||||||
|
<div class="text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
<div>
|
||||||
|
{{
|
||||||
|
formatSessionWindow(
|
||||||
|
account.sessionWindow.windowStart,
|
||||||
|
account.sessionWindow.windowEnd
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="account.sessionWindow.remainingTime > 0"
|
||||||
|
class="font-medium text-indigo-600 dark:text-indigo-400"
|
||||||
|
>
|
||||||
|
剩余 {{ formatRemainingTime(account.sessionWindow.remainingTime) }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else class="text-xs text-gray-400">暂无统计</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Claude Console: 显示每日额度使用进度 -->
|
<!-- Claude Console: 显示每日额度使用进度 -->
|
||||||
<div v-else-if="account.platform === 'claude-console'" class="space-y-2">
|
<div v-else-if="account.platform === 'claude-console'" class="space-y-2">
|
||||||
@@ -840,9 +985,6 @@
|
|||||||
<span class="text-xs">N/A</span>
|
<span class="text-xs">N/A</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="account.platform === 'claude'" class="text-sm text-gray-400">
|
|
||||||
<i class="fas fa-minus" />
|
|
||||||
</div>
|
|
||||||
<div v-else class="text-sm text-gray-400">
|
<div v-else class="text-sm text-gray-400">
|
||||||
<span class="text-xs">N/A</span>
|
<span class="text-xs">N/A</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1050,56 +1192,162 @@
|
|||||||
<!-- 状态信息 -->
|
<!-- 状态信息 -->
|
||||||
<div class="mb-3 space-y-2">
|
<div class="mb-3 space-y-2">
|
||||||
<!-- 会话窗口 -->
|
<!-- 会话窗口 -->
|
||||||
<div
|
<div v-if="account.platform === 'claude'" class="space-y-2">
|
||||||
v-if="
|
<!-- OAuth 账户:显示三窗口 OAuth usage -->
|
||||||
account.platform === 'claude' &&
|
<div v-if="isClaudeOAuth(account) && account.claudeUsage" class="space-y-2">
|
||||||
account.sessionWindow &&
|
<!-- 5小时窗口 -->
|
||||||
account.sessionWindow.hasActiveWindow
|
<div class="rounded-lg bg-gray-50 p-2 dark:bg-gray-700/70">
|
||||||
"
|
<div class="flex items-center gap-2">
|
||||||
class="space-y-1.5 rounded-lg bg-gray-50 p-2 dark:bg-gray-700"
|
<span
|
||||||
>
|
class="inline-flex min-w-[32px] justify-center rounded-full bg-indigo-100 px-2 py-0.5 text-[11px] font-medium text-indigo-600 dark:bg-indigo-500/20 dark:text-indigo-300"
|
||||||
<div class="flex items-center justify-between text-xs">
|
>
|
||||||
<div class="flex items-center gap-1">
|
5h
|
||||||
<span class="font-medium text-gray-600 dark:text-gray-300">会话窗口</span>
|
</span>
|
||||||
<el-tooltip
|
<div class="flex-1">
|
||||||
content="会话窗口进度不代表使用量,仅表示距离下一个5小时窗口的剩余时间"
|
<div class="flex items-center gap-2">
|
||||||
placement="top"
|
<div class="h-2 flex-1 rounded-full bg-gray-200 dark:bg-gray-600">
|
||||||
>
|
<div
|
||||||
<i
|
:class="[
|
||||||
class="fas fa-question-circle cursor-help text-xs text-gray-400 hover:text-gray-600"
|
'h-2 rounded-full transition-all duration-300',
|
||||||
/>
|
getClaudeUsageBarClass(account.claudeUsage.fiveHour)
|
||||||
</el-tooltip>
|
]"
|
||||||
|
:style="{
|
||||||
|
width: getClaudeUsageWidth(account.claudeUsage.fiveHour)
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="w-12 text-right text-xs font-semibold text-gray-800 dark:text-gray-100"
|
||||||
|
>
|
||||||
|
{{ formatClaudeUsagePercent(account.claudeUsage.fiveHour) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
|
||||||
|
重置剩余 {{ formatClaudeRemaining(account.claudeUsage.fiveHour) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 7天窗口 -->
|
||||||
|
<div class="rounded-lg bg-gray-50 p-2 dark:bg-gray-700/70">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="inline-flex min-w-[32px] justify-center rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-medium text-emerald-600 dark:bg-emerald-500/20 dark:text-emerald-300"
|
||||||
|
>
|
||||||
|
7d
|
||||||
|
</span>
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="h-2 flex-1 rounded-full bg-gray-200 dark:bg-gray-600">
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'h-2 rounded-full transition-all duration-300',
|
||||||
|
getClaudeUsageBarClass(account.claudeUsage.sevenDay)
|
||||||
|
]"
|
||||||
|
:style="{
|
||||||
|
width: getClaudeUsageWidth(account.claudeUsage.sevenDay)
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="w-12 text-right text-xs font-semibold text-gray-800 dark:text-gray-100"
|
||||||
|
>
|
||||||
|
{{ formatClaudeUsagePercent(account.claudeUsage.sevenDay) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
|
||||||
|
重置剩余 {{ formatClaudeRemaining(account.claudeUsage.sevenDay) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 7天Opus窗口 -->
|
||||||
|
<div class="rounded-lg bg-gray-50 p-2 dark:bg-gray-700/70">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="inline-flex min-w-[32px] justify-center rounded-full bg-purple-100 px-2 py-0.5 text-[11px] font-medium text-purple-600 dark:bg-purple-500/20 dark:text-purple-300"
|
||||||
|
>
|
||||||
|
Opus
|
||||||
|
</span>
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="h-2 flex-1 rounded-full bg-gray-200 dark:bg-gray-600">
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'h-2 rounded-full transition-all duration-300',
|
||||||
|
getClaudeUsageBarClass(account.claudeUsage.sevenDayOpus)
|
||||||
|
]"
|
||||||
|
:style="{
|
||||||
|
width: getClaudeUsageWidth(account.claudeUsage.sevenDayOpus)
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="w-12 text-right text-xs font-semibold text-gray-800 dark:text-gray-100"
|
||||||
|
>
|
||||||
|
{{ formatClaudeUsagePercent(account.claudeUsage.sevenDayOpus) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
|
||||||
|
重置剩余 {{ formatClaudeRemaining(account.claudeUsage.sevenDayOpus) }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-200">
|
|
||||||
{{ account.sessionWindow.progress }}%
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="h-2 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-gray-600">
|
<!-- Setup Token 账户:显示原有的会话窗口时间进度 -->
|
||||||
<div
|
<div
|
||||||
:class="[
|
v-else-if="
|
||||||
'h-full transition-all duration-300',
|
!isClaudeOAuth(account) &&
|
||||||
getSessionProgressBarClass(account.sessionWindow.sessionWindowStatus, account)
|
account.sessionWindow &&
|
||||||
]"
|
account.sessionWindow.hasActiveWindow
|
||||||
:style="{ width: account.sessionWindow.progress + '%' }"
|
"
|
||||||
/>
|
class="space-y-1.5 rounded-lg bg-gray-50 p-2 dark:bg-gray-700"
|
||||||
</div>
|
>
|
||||||
<div class="flex items-center justify-between text-xs">
|
<div class="flex items-center justify-between text-xs">
|
||||||
<span class="text-gray-500 dark:text-gray-400">
|
<div class="flex items-center gap-1">
|
||||||
{{
|
<span class="font-medium text-gray-600 dark:text-gray-300">会话窗口</span>
|
||||||
formatSessionWindow(
|
<el-tooltip
|
||||||
account.sessionWindow.windowStart,
|
content="会话窗口进度不代表使用量,仅表示距离下一个5小时窗口的剩余时间"
|
||||||
account.sessionWindow.windowEnd
|
placement="top"
|
||||||
)
|
>
|
||||||
}}
|
<i
|
||||||
</span>
|
class="fas fa-question-circle cursor-help text-xs text-gray-400 hover:text-gray-600"
|
||||||
<span
|
/>
|
||||||
v-if="account.sessionWindow.remainingTime > 0"
|
</el-tooltip>
|
||||||
class="font-medium text-indigo-600"
|
</div>
|
||||||
>
|
<span class="font-medium text-gray-700 dark:text-gray-200">
|
||||||
剩余 {{ formatRemainingTime(account.sessionWindow.remainingTime) }}
|
{{ account.sessionWindow.progress }}%
|
||||||
</span>
|
</span>
|
||||||
<span v-else class="text-gray-500"> 已结束 </span>
|
</div>
|
||||||
|
<div class="h-2 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-gray-600">
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'h-full transition-all duration-300',
|
||||||
|
getSessionProgressBarClass(account.sessionWindow.sessionWindowStatus, account)
|
||||||
|
]"
|
||||||
|
:style="{ width: account.sessionWindow.progress + '%' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between text-xs">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">
|
||||||
|
{{
|
||||||
|
formatSessionWindow(
|
||||||
|
account.sessionWindow.windowStart,
|
||||||
|
account.sessionWindow.windowEnd
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="account.sessionWindow.remainingTime > 0"
|
||||||
|
class="font-medium text-indigo-600"
|
||||||
|
>
|
||||||
|
剩余 {{ formatRemainingTime(account.sessionWindow.remainingTime) }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="text-gray-500"> 已结束 </span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else class="text-xs text-gray-400">暂无统计</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="account.platform === 'openai'" class="space-y-2">
|
<div v-else-if="account.platform === 'openai'" class="space-y-2">
|
||||||
<div v-if="account.codexUsage" class="space-y-2">
|
<div v-if="account.codexUsage" class="space-y-2">
|
||||||
@@ -2024,6 +2272,13 @@ const loadAccounts = async (forceReload = false) => {
|
|||||||
|
|
||||||
accounts.value = filteredAccounts
|
accounts.value = filteredAccounts
|
||||||
cleanupSelectedAccounts()
|
cleanupSelectedAccounts()
|
||||||
|
|
||||||
|
// 异步加载 Claude OAuth 账户的 usage 数据
|
||||||
|
if (filteredAccounts.some((acc) => acc.platform === 'claude')) {
|
||||||
|
loadClaudeUsage().catch((err) => {
|
||||||
|
console.debug('Claude usage loading failed:', err)
|
||||||
|
})
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast('加载账户失败', 'error')
|
showToast('加载账户失败', 'error')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -2031,6 +2286,29 @@ const loadAccounts = async (forceReload = false) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 异步加载 Claude 账户的 Usage 数据
|
||||||
|
const loadClaudeUsage = async () => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get('/admin/claude-accounts/usage')
|
||||||
|
if (response.success && response.data) {
|
||||||
|
const usageMap = response.data
|
||||||
|
|
||||||
|
// 更新账户列表中的 claudeUsage 数据
|
||||||
|
accounts.value = accounts.value.map((account) => {
|
||||||
|
if (account.platform === 'claude' && usageMap[account.id]) {
|
||||||
|
return {
|
||||||
|
...account,
|
||||||
|
claudeUsage: usageMap[account.id]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return account
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.debug('Failed to load Claude usage data:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 排序账户
|
// 排序账户
|
||||||
const sortAccounts = (field) => {
|
const sortAccounts = (field) => {
|
||||||
if (field) {
|
if (field) {
|
||||||
@@ -2800,6 +3078,70 @@ const getSessionProgressBarClass = (status, account = null) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ====== Claude OAuth Usage 相关函数 ======
|
||||||
|
|
||||||
|
// 判断 Claude 账户是否为 OAuth 授权
|
||||||
|
const isClaudeOAuth = (account) => {
|
||||||
|
return account.authType === 'oauth'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化 Claude 使用率百分比
|
||||||
|
const formatClaudeUsagePercent = (window) => {
|
||||||
|
if (!window || window.utilization === null || window.utilization === undefined) {
|
||||||
|
return '-'
|
||||||
|
}
|
||||||
|
return `${window.utilization}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取 Claude 使用率宽度
|
||||||
|
const getClaudeUsageWidth = (window) => {
|
||||||
|
if (!window || window.utilization === null || window.utilization === undefined) {
|
||||||
|
return '0%'
|
||||||
|
}
|
||||||
|
return `${window.utilization}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取 Claude 使用率进度条颜色
|
||||||
|
const getClaudeUsageBarClass = (window) => {
|
||||||
|
const util = window?.utilization || 0
|
||||||
|
if (util < 60) {
|
||||||
|
return 'bg-gradient-to-r from-blue-500 to-indigo-600'
|
||||||
|
}
|
||||||
|
if (util < 90) {
|
||||||
|
return 'bg-gradient-to-r from-yellow-500 to-orange-500'
|
||||||
|
}
|
||||||
|
return 'bg-gradient-to-r from-red-500 to-red-600'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化 Claude 剩余时间
|
||||||
|
const formatClaudeRemaining = (window) => {
|
||||||
|
if (!window || !window.remainingSeconds) {
|
||||||
|
return '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
const seconds = window.remainingSeconds
|
||||||
|
const days = Math.floor(seconds / 86400)
|
||||||
|
const hours = Math.floor((seconds % 86400) / 3600)
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60)
|
||||||
|
|
||||||
|
if (days > 0) {
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${days}天${hours}小时`
|
||||||
|
}
|
||||||
|
return `${days}天`
|
||||||
|
}
|
||||||
|
if (hours > 0) {
|
||||||
|
if (minutes > 0) {
|
||||||
|
return `${hours}小时${minutes}分钟`
|
||||||
|
}
|
||||||
|
return `${hours}小时`
|
||||||
|
}
|
||||||
|
if (minutes > 0) {
|
||||||
|
return `${minutes}分钟`
|
||||||
|
}
|
||||||
|
return `${Math.floor(seconds % 60)}秒`
|
||||||
|
}
|
||||||
|
|
||||||
// 归一化 OpenAI 会话窗口使用率
|
// 归一化 OpenAI 会话窗口使用率
|
||||||
const normalizeCodexUsagePercent = (usageItem) => {
|
const normalizeCodexUsagePercent = (usageItem) => {
|
||||||
if (!usageItem) {
|
if (!usageItem) {
|
||||||
|
|||||||
Reference in New Issue
Block a user