mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 09:38:02 +00:00
feat: 适配codex用量数据
This commit is contained in:
@@ -17,6 +17,50 @@ function createProxyAgent(proxy) {
|
|||||||
return ProxyHelper.createProxyAgent(proxy)
|
return ProxyHelper.createProxyAgent(proxy)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeHeaders(headers = {}) {
|
||||||
|
if (!headers || typeof headers !== 'object') {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
const normalized = {}
|
||||||
|
for (const [key, value] of Object.entries(headers)) {
|
||||||
|
if (!key) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
normalized[key.toLowerCase()] = Array.isArray(value) ? value[0] : value
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNumberSafe(value) {
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const num = Number(value)
|
||||||
|
return Number.isFinite(num) ? num : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractCodexUsageHeaders(headers) {
|
||||||
|
const normalized = normalizeHeaders(headers)
|
||||||
|
if (!normalized || Object.keys(normalized).length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = {
|
||||||
|
primaryUsedPercent: toNumberSafe(normalized['x-codex-primary-used-percent']),
|
||||||
|
primaryResetAfterSeconds: toNumberSafe(normalized['x-codex-primary-reset-after-seconds']),
|
||||||
|
primaryWindowMinutes: toNumberSafe(normalized['x-codex-primary-window-minutes']),
|
||||||
|
secondaryUsedPercent: toNumberSafe(normalized['x-codex-secondary-used-percent']),
|
||||||
|
secondaryResetAfterSeconds: toNumberSafe(normalized['x-codex-secondary-reset-after-seconds']),
|
||||||
|
secondaryWindowMinutes: toNumberSafe(normalized['x-codex-secondary-window-minutes']),
|
||||||
|
primaryOverSecondaryPercent: toNumberSafe(
|
||||||
|
normalized['x-codex-primary-over-secondary-limit-percent']
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasData = Object.values(snapshot).some((value) => value !== null)
|
||||||
|
return hasData ? snapshot : null
|
||||||
|
}
|
||||||
|
|
||||||
// 使用统一调度器选择 OpenAI 账户
|
// 使用统一调度器选择 OpenAI 账户
|
||||||
async function getOpenAIAuthToken(apiKeyData, sessionId = null, requestedModel = null) {
|
async function getOpenAIAuthToken(apiKeyData, sessionId = null, requestedModel = null) {
|
||||||
try {
|
try {
|
||||||
@@ -266,6 +310,15 @@ const handleResponses = async (req, res) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const codexUsageSnapshot = extractCodexUsageHeaders(upstream.headers)
|
||||||
|
if (codexUsageSnapshot) {
|
||||||
|
try {
|
||||||
|
await openaiAccountService.updateCodexUsageSnapshot(accountId, codexUsageSnapshot)
|
||||||
|
} catch (codexError) {
|
||||||
|
logger.error('⚠️ 更新 Codex 使用统计失败:', codexError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 处理 429 限流错误
|
// 处理 429 限流错误
|
||||||
if (upstream.status === 429) {
|
if (upstream.status === 429) {
|
||||||
logger.warn(`🚫 Rate limit detected for OpenAI account ${accountId} (Codex API)`)
|
logger.warn(`🚫 Rate limit detected for OpenAI account ${accountId} (Codex API)`)
|
||||||
|
|||||||
@@ -115,6 +115,85 @@ setInterval(
|
|||||||
10 * 60 * 1000
|
10 * 60 * 1000
|
||||||
)
|
)
|
||||||
|
|
||||||
|
function toNumberOrNull(value) {
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const num = Number(value)
|
||||||
|
return Number.isFinite(num) ? num : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeResetMeta(updatedAt, resetAfterSeconds) {
|
||||||
|
if (!updatedAt || resetAfterSeconds === null || resetAfterSeconds === undefined) {
|
||||||
|
return {
|
||||||
|
resetAt: null,
|
||||||
|
remainingSeconds: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedMs = Date.parse(updatedAt)
|
||||||
|
if (Number.isNaN(updatedMs)) {
|
||||||
|
return {
|
||||||
|
resetAt: null,
|
||||||
|
remainingSeconds: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetMs = updatedMs + resetAfterSeconds * 1000
|
||||||
|
return {
|
||||||
|
resetAt: new Date(resetMs).toISOString(),
|
||||||
|
remainingSeconds: Math.max(0, Math.round((resetMs - Date.now()) / 1000))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCodexUsageSnapshot(accountData) {
|
||||||
|
const updatedAt = accountData.codexUsageUpdatedAt
|
||||||
|
|
||||||
|
const primaryUsedPercent = toNumberOrNull(accountData.codexPrimaryUsedPercent)
|
||||||
|
const primaryResetAfterSeconds = toNumberOrNull(accountData.codexPrimaryResetAfterSeconds)
|
||||||
|
const primaryWindowMinutes = toNumberOrNull(accountData.codexPrimaryWindowMinutes)
|
||||||
|
const secondaryUsedPercent = toNumberOrNull(accountData.codexSecondaryUsedPercent)
|
||||||
|
const secondaryResetAfterSeconds = toNumberOrNull(accountData.codexSecondaryResetAfterSeconds)
|
||||||
|
const secondaryWindowMinutes = toNumberOrNull(accountData.codexSecondaryWindowMinutes)
|
||||||
|
const overSecondaryPercent = toNumberOrNull(accountData.codexPrimaryOverSecondaryLimitPercent)
|
||||||
|
|
||||||
|
const hasPrimaryData =
|
||||||
|
primaryUsedPercent !== null ||
|
||||||
|
primaryResetAfterSeconds !== null ||
|
||||||
|
primaryWindowMinutes !== null
|
||||||
|
const hasSecondaryData =
|
||||||
|
secondaryUsedPercent !== null ||
|
||||||
|
secondaryResetAfterSeconds !== null ||
|
||||||
|
secondaryWindowMinutes !== null
|
||||||
|
|
||||||
|
if (!updatedAt && !hasPrimaryData && !hasSecondaryData) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const primaryMeta = computeResetMeta(updatedAt, primaryResetAfterSeconds)
|
||||||
|
const secondaryMeta = computeResetMeta(updatedAt, secondaryResetAfterSeconds)
|
||||||
|
|
||||||
|
return {
|
||||||
|
updatedAt,
|
||||||
|
primary: {
|
||||||
|
usedPercent: primaryUsedPercent,
|
||||||
|
resetAfterSeconds: primaryResetAfterSeconds,
|
||||||
|
windowMinutes: primaryWindowMinutes,
|
||||||
|
resetAt: primaryMeta.resetAt,
|
||||||
|
remainingSeconds: primaryMeta.remainingSeconds
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
usedPercent: secondaryUsedPercent,
|
||||||
|
resetAfterSeconds: secondaryResetAfterSeconds,
|
||||||
|
windowMinutes: secondaryWindowMinutes,
|
||||||
|
resetAt: secondaryMeta.resetAt,
|
||||||
|
remainingSeconds: secondaryMeta.remainingSeconds
|
||||||
|
},
|
||||||
|
primaryOverSecondaryPercent: overSecondaryPercent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 刷新访问令牌
|
// 刷新访问令牌
|
||||||
async function refreshAccessToken(refreshToken, proxy = null) {
|
async function refreshAccessToken(refreshToken, proxy = null) {
|
||||||
try {
|
try {
|
||||||
@@ -650,6 +729,8 @@ async function getAllAccounts() {
|
|||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
const accountData = await client.hgetall(key)
|
const accountData = await client.hgetall(key)
|
||||||
if (accountData && Object.keys(accountData).length > 0) {
|
if (accountData && Object.keys(accountData).length > 0) {
|
||||||
|
const codexUsage = buildCodexUsageSnapshot(accountData)
|
||||||
|
|
||||||
// 解密敏感数据(但不返回给前端)
|
// 解密敏感数据(但不返回给前端)
|
||||||
if (accountData.email) {
|
if (accountData.email) {
|
||||||
accountData.email = decrypt(accountData.email)
|
accountData.email = decrypt(accountData.email)
|
||||||
@@ -657,12 +738,24 @@ async function getAllAccounts() {
|
|||||||
|
|
||||||
// 先保存 refreshToken 是否存在的标记
|
// 先保存 refreshToken 是否存在的标记
|
||||||
const hasRefreshTokenFlag = !!accountData.refreshToken
|
const hasRefreshTokenFlag = !!accountData.refreshToken
|
||||||
|
const maskedAccessToken = accountData.accessToken ? '[ENCRYPTED]' : ''
|
||||||
|
const maskedRefreshToken = accountData.refreshToken ? '[ENCRYPTED]' : ''
|
||||||
|
const maskedOauth = accountData.openaiOauth ? '[ENCRYPTED]' : ''
|
||||||
|
|
||||||
// 屏蔽敏感信息(token等不应该返回给前端)
|
// 屏蔽敏感信息(token等不应该返回给前端)
|
||||||
delete accountData.idToken
|
delete accountData.idToken
|
||||||
delete accountData.accessToken
|
delete accountData.accessToken
|
||||||
delete accountData.refreshToken
|
delete accountData.refreshToken
|
||||||
delete accountData.openaiOauth
|
delete accountData.openaiOauth
|
||||||
|
delete accountData.codexPrimaryUsedPercent
|
||||||
|
delete accountData.codexPrimaryResetAfterSeconds
|
||||||
|
delete accountData.codexPrimaryWindowMinutes
|
||||||
|
delete accountData.codexSecondaryUsedPercent
|
||||||
|
delete accountData.codexSecondaryResetAfterSeconds
|
||||||
|
delete accountData.codexSecondaryWindowMinutes
|
||||||
|
delete accountData.codexPrimaryOverSecondaryLimitPercent
|
||||||
|
// 时间戳改由 codexUsage.updatedAt 暴露
|
||||||
|
delete accountData.codexUsageUpdatedAt
|
||||||
|
|
||||||
// 获取限流状态信息
|
// 获取限流状态信息
|
||||||
const rateLimitInfo = await getAccountRateLimitInfo(accountData.id)
|
const rateLimitInfo = await getAccountRateLimitInfo(accountData.id)
|
||||||
@@ -682,9 +775,9 @@ async function getAllAccounts() {
|
|||||||
...accountData,
|
...accountData,
|
||||||
isActive: accountData.isActive === 'true',
|
isActive: accountData.isActive === 'true',
|
||||||
schedulable: accountData.schedulable !== 'false',
|
schedulable: accountData.schedulable !== 'false',
|
||||||
openaiOauth: accountData.openaiOauth ? '[ENCRYPTED]' : '',
|
openaiOauth: maskedOauth,
|
||||||
accessToken: accountData.accessToken ? '[ENCRYPTED]' : '',
|
accessToken: maskedAccessToken,
|
||||||
refreshToken: accountData.refreshToken ? '[ENCRYPTED]' : '',
|
refreshToken: maskedRefreshToken,
|
||||||
// 添加 scopes 字段用于判断认证方式
|
// 添加 scopes 字段用于判断认证方式
|
||||||
// 处理空字符串的情况
|
// 处理空字符串的情况
|
||||||
scopes:
|
scopes:
|
||||||
@@ -706,7 +799,8 @@ async function getAllAccounts() {
|
|||||||
rateLimitedAt: null,
|
rateLimitedAt: null,
|
||||||
rateLimitResetAt: null,
|
rateLimitResetAt: null,
|
||||||
minutesRemaining: 0
|
minutesRemaining: 0
|
||||||
}
|
},
|
||||||
|
codexUsage
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1043,6 +1137,41 @@ async function updateAccountUsage(accountId, tokens = 0) {
|
|||||||
// 为了兼容性,保留recordUsage作为updateAccountUsage的别名
|
// 为了兼容性,保留recordUsage作为updateAccountUsage的别名
|
||||||
const recordUsage = updateAccountUsage
|
const recordUsage = updateAccountUsage
|
||||||
|
|
||||||
|
async function updateCodexUsageSnapshot(accountId, usageSnapshot) {
|
||||||
|
if (!usageSnapshot || typeof usageSnapshot !== 'object') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldMap = {
|
||||||
|
primaryUsedPercent: 'codexPrimaryUsedPercent',
|
||||||
|
primaryResetAfterSeconds: 'codexPrimaryResetAfterSeconds',
|
||||||
|
primaryWindowMinutes: 'codexPrimaryWindowMinutes',
|
||||||
|
secondaryUsedPercent: 'codexSecondaryUsedPercent',
|
||||||
|
secondaryResetAfterSeconds: 'codexSecondaryResetAfterSeconds',
|
||||||
|
secondaryWindowMinutes: 'codexSecondaryWindowMinutes',
|
||||||
|
primaryOverSecondaryPercent: 'codexPrimaryOverSecondaryLimitPercent'
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates = {}
|
||||||
|
let hasPayload = false
|
||||||
|
|
||||||
|
for (const [key, field] of Object.entries(fieldMap)) {
|
||||||
|
if (usageSnapshot[key] !== undefined && usageSnapshot[key] !== null) {
|
||||||
|
updates[field] = String(usageSnapshot[key])
|
||||||
|
hasPayload = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPayload) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updates.codexUsageUpdatedAt = new Date().toISOString()
|
||||||
|
|
||||||
|
const client = redisClient.getClientSafe()
|
||||||
|
await client.hset(`${OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, updates)
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
createAccount,
|
createAccount,
|
||||||
getAccount,
|
getAccount,
|
||||||
@@ -1059,6 +1188,7 @@ module.exports = {
|
|||||||
getAccountRateLimitInfo,
|
getAccountRateLimitInfo,
|
||||||
updateAccountUsage,
|
updateAccountUsage,
|
||||||
recordUsage, // 别名,指向updateAccountUsage
|
recordUsage, // 别名,指向updateAccountUsage
|
||||||
|
updateCodexUsageSnapshot,
|
||||||
encrypt,
|
encrypt,
|
||||||
decrypt,
|
decrypt,
|
||||||
generateEncryptionKey,
|
generateEncryptionKey,
|
||||||
|
|||||||
@@ -654,6 +654,82 @@
|
|||||||
<i class="fas fa-minus" />
|
<i class="fas fa-minus" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else-if="account.platform === 'openai'" class="space-y-2">
|
||||||
|
<div v-if="account.codexUsage" class="space-y-2">
|
||||||
|
<div class="space-y-1 rounded-lg bg-gray-50 p-2 dark:bg-gray-700/70">
|
||||||
|
<div class="flex items-center justify-between text-xs">
|
||||||
|
<div class="flex items-center gap-1 text-gray-600 dark:text-gray-300">
|
||||||
|
<i class="fas fa-hourglass-half text-indigo-500" />
|
||||||
|
<span>5小时窗口</span>
|
||||||
|
</div>
|
||||||
|
<span class="font-semibold text-gray-800 dark:text-gray-100">
|
||||||
|
{{ formatCodexUsagePercent(account.codexUsage.primary.usedPercent) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="h-2 w-24 rounded-full bg-gray-200 dark:bg-gray-600">
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'h-2 rounded-full transition-all duration-300',
|
||||||
|
getCodexUsageBarClass(account.codexUsage.primary.usedPercent)
|
||||||
|
]"
|
||||||
|
:style="{ width: getCodexUsageWidth(account.codexUsage.primary.usedPercent) }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span class="text-[11px] text-gray-500 dark:text-gray-400">
|
||||||
|
{{ formatCodexWindowDisplay(account.codexUsage.primary.windowMinutes) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-[11px] text-gray-500 dark:text-gray-400">
|
||||||
|
重置剩余 {{ formatCodexRemaining(account.codexUsage.primary) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1 rounded-lg bg-gray-50 p-2 dark:bg-gray-700/70">
|
||||||
|
<div class="flex items-center justify-between text-xs">
|
||||||
|
<div class="flex items-center gap-1 text-gray-600 dark:text-gray-300">
|
||||||
|
<i class="fas fa-calendar-week text-blue-500" />
|
||||||
|
<span>7天窗口</span>
|
||||||
|
</div>
|
||||||
|
<span class="font-semibold text-gray-800 dark:text-gray-100">
|
||||||
|
{{ formatCodexUsagePercent(account.codexUsage.secondary.usedPercent) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="h-2 w-24 rounded-full bg-gray-200 dark:bg-gray-600">
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'h-2 rounded-full transition-all duration-300',
|
||||||
|
getCodexUsageBarClass(account.codexUsage.secondary.usedPercent)
|
||||||
|
]"
|
||||||
|
:style="{ width: getCodexUsageWidth(account.codexUsage.secondary.usedPercent) }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span class="text-[11px] text-gray-500 dark:text-gray-400">
|
||||||
|
{{ formatCodexWindowDisplay(account.codexUsage.secondary.windowMinutes) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-[11px] text-gray-500 dark:text-gray-400">
|
||||||
|
重置剩余 {{ formatCodexRemaining(account.codexUsage.secondary) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="account.codexUsage.primaryOverSecondaryPercent !== null && account.codexUsage.primaryOverSecondaryPercent !== undefined"
|
||||||
|
class="text-[11px] text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
短期/长期占比
|
||||||
|
{{ formatCodexUsagePercent(account.codexUsage.primaryOverSecondaryPercent) }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="account.codexUsage.updatedAt"
|
||||||
|
class="text-[11px] text-gray-400 dark:text-gray-500"
|
||||||
|
>
|
||||||
|
更新 {{ formatRelativeTime(account.codexUsage.updatedAt) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-sm text-gray-400">
|
||||||
|
<span class="text-xs">N/A</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div v-else-if="account.platform === 'claude'" class="text-sm text-gray-400">
|
<div v-else-if="account.platform === 'claude'" class="text-sm text-gray-400">
|
||||||
<i class="fas fa-minus" />
|
<i class="fas fa-minus" />
|
||||||
</div>
|
</div>
|
||||||
@@ -898,6 +974,78 @@
|
|||||||
<span v-else class="text-gray-500"> 已结束 </span>
|
<span v-else class="text-gray-500"> 已结束 </span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else-if="account.platform === 'openai'" class="space-y-2">
|
||||||
|
<div v-if="account.codexUsage" class="space-y-2 rounded-lg bg-gray-50 p-2 dark:bg-gray-700">
|
||||||
|
<div class="flex items-center justify-between text-xs">
|
||||||
|
<div class="flex items-center gap-1 text-gray-600 dark:text-gray-300">
|
||||||
|
<i class="fas fa-hourglass-half text-indigo-500" />
|
||||||
|
<span>5小时窗口</span>
|
||||||
|
</div>
|
||||||
|
<span class="font-semibold text-gray-800 dark:text-gray-100">
|
||||||
|
{{ formatCodexUsagePercent(account.codexUsage.primary.usedPercent) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="h-2 w-full rounded-full bg-gray-200 dark:bg-gray-600">
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'h-2 rounded-full transition-all duration-300',
|
||||||
|
getCodexUsageBarClass(account.codexUsage.primary.usedPercent)
|
||||||
|
]"
|
||||||
|
:style="{ width: getCodexUsageWidth(account.codexUsage.primary.usedPercent) }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span class="text-[11px] text-gray-500 dark:text-gray-400">
|
||||||
|
{{ formatCodexWindowDisplay(account.codexUsage.primary.windowMinutes) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-[11px] text-gray-500 dark:text-gray-400">
|
||||||
|
重置剩余 {{ formatCodexRemaining(account.codexUsage.primary) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="account.codexUsage" class="space-y-2 rounded-lg bg-gray-50 p-2 dark:bg-gray-700">
|
||||||
|
<div class="flex items-center justify-between text-xs">
|
||||||
|
<div class="flex items-center gap-1 text-gray-600 dark:text-gray-300">
|
||||||
|
<i class="fas fa-calendar-week text-blue-500" />
|
||||||
|
<span>7天窗口</span>
|
||||||
|
</div>
|
||||||
|
<span class="font-semibold text-gray-800 dark:text-gray-100">
|
||||||
|
{{ formatCodexUsagePercent(account.codexUsage.secondary.usedPercent) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="h-2 w-full rounded-full bg-gray-200 dark:bg-gray-600">
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'h-2 rounded-full transition-all duration-300',
|
||||||
|
getCodexUsageBarClass(account.codexUsage.secondary.usedPercent)
|
||||||
|
]"
|
||||||
|
:style="{ width: getCodexUsageWidth(account.codexUsage.secondary.usedPercent) }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span class="text-[11px] text-gray-500 dark:text-gray-400">
|
||||||
|
{{ formatCodexWindowDisplay(account.codexUsage.secondary.windowMinutes) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-[11px] text-gray-500 dark:text-gray-400">
|
||||||
|
重置剩余 {{ formatCodexRemaining(account.codexUsage.secondary) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="account.codexUsage && account.codexUsage.primaryOverSecondaryPercent !== null && account.codexUsage.primaryOverSecondaryPercent !== undefined"
|
||||||
|
class="text-[11px] text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
短期/长期占比
|
||||||
|
{{ formatCodexUsagePercent(account.codexUsage.primaryOverSecondaryPercent) }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="account.codexUsage && account.codexUsage.updatedAt"
|
||||||
|
class="text-[11px] text-gray-400 dark:text-gray-500"
|
||||||
|
>
|
||||||
|
更新 {{ formatRelativeTime(account.codexUsage.updatedAt) }}
|
||||||
|
</div>
|
||||||
|
<div v-if="!account.codexUsage" class="text-xs text-gray-400">暂无统计</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 最后使用时间 -->
|
<!-- 最后使用时间 -->
|
||||||
<div class="flex items-center justify-between text-xs">
|
<div class="flex items-center justify-between text-xs">
|
||||||
@@ -2051,6 +2199,102 @@ const getSessionProgressBarClass = (status, account = null) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OpenAI 限额进度条颜色
|
||||||
|
const getCodexUsageBarClass = (percent) => {
|
||||||
|
if (percent === null || percent === undefined || Number.isNaN(percent)) {
|
||||||
|
return 'bg-gradient-to-r from-gray-300 to-gray-400'
|
||||||
|
}
|
||||||
|
if (percent >= 90) {
|
||||||
|
return 'bg-gradient-to-r from-red-500 to-red-600'
|
||||||
|
}
|
||||||
|
if (percent >= 75) {
|
||||||
|
return 'bg-gradient-to-r from-yellow-500 to-orange-500'
|
||||||
|
}
|
||||||
|
return 'bg-gradient-to-r from-emerald-500 to-teal-500'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 百分比显示
|
||||||
|
const formatCodexUsagePercent = (percent) => {
|
||||||
|
if (percent === null || percent === undefined || Number.isNaN(percent)) {
|
||||||
|
return '--'
|
||||||
|
}
|
||||||
|
return `${percent.toFixed(1)}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 进度条宽度
|
||||||
|
const getCodexUsageWidth = (percent) => {
|
||||||
|
if (percent === null || percent === undefined || Number.isNaN(percent)) {
|
||||||
|
return '0%'
|
||||||
|
}
|
||||||
|
const clamped = Math.max(0, Math.min(100, percent))
|
||||||
|
return `${clamped}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化窗口时长
|
||||||
|
const formatCodexWindowDisplay = (minutes) => {
|
||||||
|
if (!minutes || Number.isNaN(Number(minutes))) {
|
||||||
|
return '窗口 --'
|
||||||
|
}
|
||||||
|
const value = Number(minutes)
|
||||||
|
if (value >= 1440) {
|
||||||
|
const days = Math.floor(value / 1440)
|
||||||
|
const hours = Math.floor((value % 1440) / 60)
|
||||||
|
if (hours > 0) {
|
||||||
|
return `窗口 ${days}天${hours}小时`
|
||||||
|
}
|
||||||
|
return `窗口 ${days}天`
|
||||||
|
}
|
||||||
|
if (value >= 60) {
|
||||||
|
const hours = Math.floor(value / 60)
|
||||||
|
const remain = value % 60
|
||||||
|
if (remain > 0) {
|
||||||
|
return `窗口 ${hours}小时${remain}分钟`
|
||||||
|
}
|
||||||
|
return `窗口 ${hours}小时`
|
||||||
|
}
|
||||||
|
return `窗口 ${value}分钟`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化剩余时间
|
||||||
|
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}秒`
|
||||||
|
}
|
||||||
|
|
||||||
// 格式化费用显示
|
// 格式化费用显示
|
||||||
const formatCost = (cost) => {
|
const formatCost = (cost) => {
|
||||||
if (!cost || cost === 0) return '0.0000'
|
if (!cost || cost === 0) return '0.0000'
|
||||||
|
|||||||
Reference in New Issue
Block a user