mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 09:38:02 +00:00
feat: api-stats页面查询专属账号会话窗口
This commit is contained in:
@@ -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
|
||||||
},
|
},
|
||||||
|
|
||||||
// 模型和客户端限制信息
|
// 模型和客户端限制信息
|
||||||
|
|||||||
@@ -518,6 +518,60 @@ 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,
|
||||||
|
name: accountData.name,
|
||||||
|
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 {
|
||||||
|
|||||||
@@ -808,6 +808,48 @@ 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,
|
||||||
|
name: accountData.name,
|
||||||
|
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 +1217,7 @@ async function updateCodexUsageSnapshot(accountId, usageSnapshot) {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
createAccount,
|
createAccount,
|
||||||
getAccount,
|
getAccount,
|
||||||
|
getAccountOverview,
|
||||||
updateAccount,
|
updateAccount,
|
||||||
deleteAccount,
|
deleteAccount,
|
||||||
getAllAccounts,
|
getAllAccounts,
|
||||||
|
|||||||
@@ -157,6 +157,200 @@
|
|||||||
永不过期
|
永不过期
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="boundAccountList.length > 0"
|
||||||
|
class="mt-4 rounded-2xl border border-indigo-100/60 bg-indigo-50/60 p-4 dark:border-indigo-500/40 dark:bg-indigo-500/10"
|
||||||
|
>
|
||||||
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<i class="fas fa-link text-sm text-indigo-500 md:text-base" />
|
||||||
|
<span class="text-sm font-semibold text-indigo-900 dark:text-indigo-200 md:text-base"
|
||||||
|
>专属账号运行状态</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="rounded-full bg-white/70 px-2 py-0.5 text-xs font-medium text-indigo-500 shadow-sm dark:bg-slate-900/40 dark:text-indigo-200"
|
||||||
|
>实时速览</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3 md:space-y-4">
|
||||||
|
<div
|
||||||
|
v-for="account in boundAccountList"
|
||||||
|
:key="account.id"
|
||||||
|
class="rounded-xl bg-white/80 p-3 shadow-sm backdrop-blur dark:bg-slate-900/50 md:p-4"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full text-white shadow-inner',
|
||||||
|
account.platform === 'claude'
|
||||||
|
? 'bg-gradient-to-br from-purple-500 to-purple-600'
|
||||||
|
: 'bg-gradient-to-br from-sky-500 to-indigo-500'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<i :class="account.platform === 'claude' ? 'fas fa-meteor' : 'fas fa-robot'" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ account.name || '未命名账号' }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 flex items-center gap-2 text-[11px]">
|
||||||
|
<span
|
||||||
|
v-if="account.platform === 'claude'"
|
||||||
|
class="inline-flex items-center rounded-full bg-purple-100 px-2 py-0.5 font-medium text-purple-700 dark:bg-purple-500/20 dark:text-purple-200"
|
||||||
|
>Claude 专属</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 font-medium text-blue-700 dark:bg-blue-500/20 dark:text-blue-200"
|
||||||
|
>OpenAI 专属</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="getRateLimitDisplay(account.rateLimitStatus)"
|
||||||
|
class="text-xs font-semibold"
|
||||||
|
:class="getRateLimitDisplay(account.rateLimitStatus).class"
|
||||||
|
>
|
||||||
|
<i class="fas fa-tachometer-alt mr-1" />
|
||||||
|
{{ getRateLimitDisplay(account.rateLimitStatus).text }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="account.platform === 'claude'" class="mt-3 space-y-3">
|
||||||
|
<div v-if="account.sessionWindow?.hasActiveWindow" class="space-y-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="h-2 w-32 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: `${Math.min(
|
||||||
|
100,
|
||||||
|
Math.max(0, account.sessionWindow?.progress || 0)
|
||||||
|
)}%`
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs font-semibold text-gray-700 dark:text-gray-200">
|
||||||
|
{{
|
||||||
|
Math.min(
|
||||||
|
100,
|
||||||
|
Math.max(0, Math.round(account.sessionWindow?.progress || 0))
|
||||||
|
)
|
||||||
|
}}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex flex-wrap items-center gap-3 text-[11px] text-gray-600 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{{
|
||||||
|
formatSessionWindowRange(
|
||||||
|
account.sessionWindow?.windowStart,
|
||||||
|
account.sessionWindow?.windowEnd
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="account.sessionWindow?.remainingTime > 0"
|
||||||
|
class="font-medium text-indigo-600 dark:text-indigo-400"
|
||||||
|
>
|
||||||
|
剩余 {{ formatSessionRemaining(account.sessionWindow.remainingTime) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="rounded-lg bg-white/60 px-3 py-2 text-xs text-gray-500 dark:bg-slate-800/60 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
暂无活跃会话窗口
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="account.platform === 'openai'" class="mt-3 space-y-3">
|
||||||
|
<div v-if="account.codexUsage" class="space-y-3">
|
||||||
|
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-700/70">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="inline-flex min-w-[34px] 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"
|
||||||
|
>
|
||||||
|
{{ getCodexWindowLabel('primary') }}
|
||||||
|
</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',
|
||||||
|
getCodexUsageBarClass(account.codexUsage.primary)
|
||||||
|
]"
|
||||||
|
:style="{ width: getCodexUsageWidth(account.codexUsage.primary) }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="w-12 text-right text-xs font-semibold text-gray-800 dark:text-gray-100"
|
||||||
|
>
|
||||||
|
{{ formatCodexUsagePercent(account.codexUsage.primary) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
|
||||||
|
重置剩余 {{ formatCodexRemaining(account.codexUsage.primary) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg bg-gray-50 p-3 dark:bg-gray-700/70">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="inline-flex min-w-[34px] justify-center rounded-full bg-blue-100 px-2 py-0.5 text-[11px] font-medium text-blue-600 dark:bg-blue-500/20 dark:text-blue-200"
|
||||||
|
>
|
||||||
|
{{ getCodexWindowLabel('secondary') }}
|
||||||
|
</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',
|
||||||
|
getCodexUsageBarClass(account.codexUsage.secondary)
|
||||||
|
]"
|
||||||
|
:style="{ width: getCodexUsageWidth(account.codexUsage.secondary) }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="w-12 text-right text-xs font-semibold text-gray-800 dark:text-gray-100"
|
||||||
|
>
|
||||||
|
{{ formatCodexUsagePercent(account.codexUsage.secondary) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
|
||||||
|
重置剩余 {{ formatCodexRemaining(account.codexUsage.secondary) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="rounded-lg bg-white/60 px-3 py-2 text-xs text-gray-500 dark:bg-slate-800/60 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
暂无额度使用数据
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -313,6 +507,261 @@ const formatPermissions = (permissions) => {
|
|||||||
|
|
||||||
return permissionMap[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 formatRateLimitTime = (minutes) => {
|
||||||
|
if (!minutes || minutes <= 0) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalMinutes = Math.floor(minutes)
|
||||||
|
const days = Math.floor(totalMinutes / 1440)
|
||||||
|
const hours = Math.floor((totalMinutes % 1440) / 60)
|
||||||
|
const mins = totalMinutes % 60
|
||||||
|
|
||||||
|
if (days > 0) {
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${days}天${hours}小时`
|
||||||
|
}
|
||||||
|
return `${days}天`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
if (mins > 0) {
|
||||||
|
return `${hours}小时${mins}分钟`
|
||||||
|
}
|
||||||
|
return `${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-green-400'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化会话窗口的时间范围
|
||||||
|
const formatSessionWindowRange = (start, end) => {
|
||||||
|
if (!start || !end) {
|
||||||
|
return '暂无时间窗口信息'
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDate = new Date(start)
|
||||||
|
const endDate = new Date(end)
|
||||||
|
|
||||||
|
const formatPart = (date) => {
|
||||||
|
const hours = `${date.getHours()}`.padStart(2, '0')
|
||||||
|
const minutes = `${date.getMinutes()}`.padStart(2, '0')
|
||||||
|
return `${hours}:${minutes}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${formatPart(startDate)} - ${formatPart(endDate)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化会话窗口剩余时间
|
||||||
|
const formatSessionRemaining = (minutes) => {
|
||||||
|
if (!minutes || minutes <= 0) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const hours = Math.floor(minutes / 60)
|
||||||
|
const mins = minutes % 60
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}小时${mins}分钟`
|
||||||
|
}
|
||||||
|
return `${mins}分钟`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 会话窗口进度条颜色
|
||||||
|
const getSessionProgressBarClass = (status, account) => {
|
||||||
|
if (!status) {
|
||||||
|
return 'bg-gradient-to-r from-blue-500 to-indigo-600'
|
||||||
|
}
|
||||||
|
|
||||||
|
const isRateLimited = account?.rateLimitStatus?.isRateLimited
|
||||||
|
|
||||||
|
if (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-600'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 归一化 OpenAI 额度使用百分比
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAI 额度进度条颜色
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAI 额度进度条宽度
|
||||||
|
const getCodexUsageWidth = (usageItem) => {
|
||||||
|
const percent = normalizeCodexUsagePercent(usageItem)
|
||||||
|
if (percent === null) {
|
||||||
|
return '0%'
|
||||||
|
}
|
||||||
|
return `${percent}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAI 额度百分比文本
|
||||||
|
const formatCodexUsagePercent = (usageItem) => {
|
||||||
|
const percent = normalizeCodexUsagePercent(usageItem)
|
||||||
|
if (percent === null) {
|
||||||
|
return '--'
|
||||||
|
}
|
||||||
|
return `${percent.toFixed(1)}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAI 额度剩余时间
|
||||||
|
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) {
|
||||||
|
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 `${secs}秒`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAI 窗口标签
|
||||||
|
const getCodexWindowLabel = (type) => {
|
||||||
|
if (type === 'secondary') {
|
||||||
|
return '周限'
|
||||||
|
}
|
||||||
|
return '5h'
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
Reference in New Issue
Block a user