feat(usage): 改进用量显示并添加按提供商刷新功能

- 添加 formatToLocal 工具函数统一处理日期本地化显示
- 用量卡片添加重置时间显示和双击刷新功能
- 优化 Gemini/Antigravity/Codex 的用量数据格式化逻辑
- 改进 Codex 用量显示,优先展示周限制信息
- 添加相关国际化文本支持
This commit is contained in:
hex2077 2026-01-28 16:51:45 +08:00
parent 4406b7d881
commit 3b0c2180d2
8 changed files with 265 additions and 178 deletions

View file

@ -1468,29 +1468,32 @@ export class AntigravityApiService {
};
const res = await this.authClient.request(requestOptions);
logger.info(`[Antigravity] fetchAvailableModels success`);
if (res.data && res.data.models) {
const modelsData = res.data.models;
// 遍历模型数据,提取配额信息
for (const [modelId, modelData] of Object.entries(modelsData)) {
const aliasName = modelName2Alias(modelId);
if (aliasName == null || aliasName === '') continue; // 跳过不支持的模型
// logger.info(`[Antigravity] fetchAvailableModels success: ${JSON.stringify(res.data)}`);
if (res.data) {
if (res.data.models) {
const modelsData = res.data.models;
const modelInfo = {
remaining: 0,
resetTime: null,
resetTimeRaw: null
};
// 从 quotaInfo 中提取配额信息
if (modelData.quotaInfo) {
modelInfo.remaining = modelData.quotaInfo.remainingFraction || modelData.quotaInfo.remaining || 0;
modelInfo.resetTime = modelData.quotaInfo.resetTime || null;
modelInfo.resetTimeRaw = modelData.quotaInfo.resetTime;
// 遍历模型数据,提取配额信息
for (const [modelId, modelData] of Object.entries(modelsData)) {
const aliasName = modelName2Alias(modelId);
if (aliasName == null || aliasName === '') continue; // 跳过不支持的模型
const modelInfo = {
remaining: 0,
resetTime: null,
resetTimeRaw: null
};
// 从 quotaInfo 中提取配额信息
if (modelData.quotaInfo) {
modelInfo.remaining = modelData.quotaInfo.remainingFraction !== undefined ? modelData.quotaInfo.remainingFraction : (modelData.quotaInfo.remaining || 0);
modelInfo.resetTime = modelData.quotaInfo.resetTime || null;
modelInfo.resetTimeRaw = modelData.quotaInfo.resetTime;
}
result.models[aliasName] = modelInfo;
}
result.models[aliasName] = modelInfo;
}
// 对模型按名称排序

View file

@ -542,7 +542,7 @@ export class CodexApiService {
if (primaryWindow) {
// remaining = 1 - (used_percent / 100)
const remaining = 1 - (primaryWindow.used_percent || 0) / 100;
const resetTime = primaryWindow.reset_at ? new Date(primaryWindow.reset_at * 1000).toISOString() : null;
const resetTime = primaryWindow.reset_at ? new Date(primaryWindow.reset_at * 1000).toDateString() : null;
// 为所有 Codex 模型设置相同的配额信息
const codexModels = ['default'];

View file

@ -359,9 +359,9 @@ export class ProviderPoolManager {
async _refreshNodeToken(providerType, providerStatus, force = false) {
const config = providerStatus.config;
// 检查刷新次数是否已达上限(最大3次)
// 检查刷新次数是否已达上限(最大5次)
const currentRefreshCount = config.refreshCount || 0;
if (currentRefreshCount >= 3 && !force) {
if (currentRefreshCount >= 5 && !force) {
this._log('warn', `Node ${providerStatus.uuid} has reached maximum refresh count (3), marking as unhealthy`);
// 标记为不健康
this.markProviderUnhealthyImmediately(providerType, config, 'Maximum refresh count (3) reached');

View file

@ -5,7 +5,7 @@
import { getProviderPoolManager } from './service-manager.js';
import { serviceInstances } from '../providers/adapter.js';
import { MODEL_PROVIDER } from '../utils/common.js';
import { MODEL_PROVIDER, formatToLocal } from '../utils/common.js';
/**
* 用量查询服务类
@ -314,31 +314,6 @@ export function formatGeminiUsage(usageData) {
return null;
}
const TZ_OFFSET = 8 * 60 * 60 * 1000; // Beijing timezone offset
/**
* UTC 时间转换为北京时间
* @param {string} utcString - UTC 时间字符串
* @returns {string} 北京时间字符串
*/
function utcToBeijing(utcString) {
try {
if (!utcString) return '--';
const utcDate = new Date(utcString);
const beijingTime = new Date(utcDate.getTime() + TZ_OFFSET);
return beijingTime
.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
.replace(/\//g, '-');
} catch (e) {
return '--';
}
}
const result = {
// 基本信息 - 映射到 Kiro 结构
daysUntilReset: null,
@ -377,16 +352,20 @@ export function formatGeminiUsage(usageData) {
// 解析模型配额信息
if (usageData.models && typeof usageData.models === 'object') {
for (const [modelName, modelInfo] of Object.entries(usageData.models)) {
// Gemini 返回的数据结构:{ remaining, resetTime, resetTimeRaw }
for (const [modelKey, modelInfo] of Object.entries(usageData.models)) {
// Gemini 返回的数据结构:{ remaining, resetTime, resetTimeRaw, tokenType }
// remaining 是 0-1 之间的比例值,表示剩余配额百分比
const remainingPercent = typeof modelInfo.remaining === 'number' ? modelInfo.remaining : 1;
const usedPercent = 1 - remainingPercent;
// 解析 modelKey (modelId:tokenType)
const [modelId, tokenType] = modelKey.split(':');
const displayName = tokenType ? `${modelId} (${tokenType})` : modelId;
const item = {
resourceType: 'MODEL_USAGE',
displayName: modelInfo.displayName || modelName,
displayNamePlural: modelInfo.displayName || modelName,
displayName: displayName,
displayNamePlural: displayName,
unit: 'quota',
currency: null,
@ -411,13 +390,14 @@ export function formatGeminiUsage(usageData) {
bonuses: [],
// 额外的 Gemini 特有信息
modelName: modelName,
modelName: modelId,
tokenType: tokenType,
inputTokenLimit: modelInfo.inputTokenLimit || 0,
outputTokenLimit: modelInfo.outputTokenLimit || 0,
remaining: remainingPercent,
remainingPercent: Math.round(remainingPercent * 100), // 剩余百分比
resetTime: (modelInfo.resetTimeRaw || modelInfo.resetTime) ?
utcToBeijing(modelInfo.resetTimeRaw || modelInfo.resetTime) : '--',
formatToLocal(modelInfo.resetTimeRaw || modelInfo.resetTime) : '--',
resetTimeRaw: modelInfo.resetTimeRaw || modelInfo.resetTime || null
};
@ -438,31 +418,6 @@ export function formatAntigravityUsage(usageData) {
return null;
}
const TZ_OFFSET = 8 * 60 * 60 * 1000; // Beijing timezone offset
/**
* UTC 时间转换为北京时间
* @param {string} utcString - UTC 时间字符串
* @returns {string} 北京时间字符串
*/
function utcToBeijing(utcString) {
try {
if (!utcString) return '--';
const utcDate = new Date(utcString);
const beijingTime = new Date(utcDate.getTime() + TZ_OFFSET);
return beijingTime
.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
.replace(/\//g, '-');
} catch (e) {
return '--';
}
}
const result = {
// 基本信息 - 映射到 Kiro 结构
daysUntilReset: null,
@ -507,6 +462,10 @@ export function formatAntigravityUsage(usageData) {
const remainingPercent = typeof modelInfo.remaining === 'number' ? modelInfo.remaining : 1;
const usedPercent = 1 - remainingPercent;
// 优先使用模型自己的重置时间,如果没有则使用全局重置时间
const resetTimeRaw = modelInfo.resetTimeRaw || (usageData.quotaInfo ? usageData.quotaInfo.quotaResetTime : null);
const resetTimeFormatted = resetTimeRaw ? formatToLocal(resetTimeRaw) : (modelInfo.resetTime || '--');
const item = {
resourceType: 'MODEL_USAGE',
displayName: modelInfo.displayName || modelName,
@ -515,7 +474,7 @@ export function formatAntigravityUsage(usageData) {
currency: null,
// 当前用量 - Antigravity 返回的是剩余比例,转换为已用比例(百分比形式)
currentUsage: usedPercent * 100,
currentUsage: Math.round(usedPercent * 100 * 100) / 100,
usageLimit: 100, // 以百分比表示,总量为 100%
// 超额信息
@ -525,8 +484,7 @@ export function formatAntigravityUsage(usageData) {
overageCharges: 0,
// 下次重置时间
nextDateReset: modelInfo.resetTimeRaw ? new Date(modelInfo.resetTimeRaw).toISOString() :
(modelInfo.resetTime ? new Date(modelInfo.resetTime).toISOString() : null),
nextDateReset: resetTimeRaw ? (typeof resetTimeRaw === 'number' ? new Date(resetTimeRaw * 1000).toISOString() : new Date(resetTimeRaw).toISOString()) : null,
// 免费试用信息
freeTrial: null,
@ -539,10 +497,9 @@ export function formatAntigravityUsage(usageData) {
inputTokenLimit: modelInfo.inputTokenLimit || 0,
outputTokenLimit: modelInfo.outputTokenLimit || 0,
remaining: remainingPercent,
remainingPercent: remainingPercent * 100, // 剩余百分比
resetTime: (modelInfo.resetTimeRaw || modelInfo.resetTime) ?
utcToBeijing(modelInfo.resetTimeRaw || modelInfo.resetTime) : '--',
resetTimeRaw: modelInfo.resetTimeRaw || modelInfo.resetTime || null
remainingPercent: Math.round(remainingPercent * 100 * 100) / 100, // 剩余百分比
resetTime: resetTimeFormatted,
resetTimeRaw: resetTimeRaw
};
result.usageBreakdown.push(item);
@ -562,31 +519,6 @@ export function formatCodexUsage(usageData) {
return null;
}
const TZ_OFFSET = 8 * 60 * 60 * 1000; // Beijing timezone offset
/**
* UTC 时间转换为北京时间
* @param {string} utcString - UTC 时间字符串
* @returns {string} 北京时间字符串
*/
function utcToBeijing(utcString) {
try {
if (!utcString) return '--';
const utcDate = new Date(utcString);
const beijingTime = new Date(utcDate.getTime() + TZ_OFFSET);
return beijingTime
.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
.replace(/\//g, '-');
} catch (e) {
return '--';
}
}
const result = {
// 基本信息 - 映射到 Kiro 结构
daysUntilReset: null,
@ -661,7 +593,7 @@ export function formatCodexUsage(usageData) {
remaining: remainingPercent,
remainingPercent: Math.round(remainingPercent * 100), // 剩余百分比
resetTime: (modelInfo.resetTimeRaw || modelInfo.resetTime) ?
utcToBeijing(modelInfo.resetTimeRaw ? new Date(modelInfo.resetTimeRaw * 1000).toISOString() : modelInfo.resetTime) : '--',
formatToLocal(modelInfo.resetTimeRaw || modelInfo.resetTime) : '--',
resetTimeRaw: modelInfo.resetTimeRaw || modelInfo.resetTime || null,
// 注入 raw 窗口信息以便前端使用

View file

@ -1155,6 +1155,34 @@ export function getMD5Hash(obj) {
return crypto.createHash('md5').update(jsonString).digest('hex');
}
/**
* 将日期转换为系统本地时间格式
* @param {string|number} dateInput - 日期字符串或时间戳
* @returns {string} 格式化后的时间字符串
*/
export function formatToLocal(dateInput) {
try {
if (!dateInput) return '--';
// 处理数值型时间戳(秒 -> 毫秒)
let finalInput = dateInput;
if (typeof dateInput === 'number' && dateInput < 10000000000) {
finalInput = dateInput * 1000;
}
const date = new Date(finalInput);
if (isNaN(date.getTime())) return '--';
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
}).replace(/\//g, '-');
} catch (e) {
return '--';
}
}
/**
* 创建符合 fromProvider 格式的错误响应非流式

View file

@ -528,11 +528,22 @@ const translations = {
'usage.card.status.healthy': '健康',
'usage.card.status.unhealthy': '异常',
'usage.card.totalUsage': '总用量',
'usage.card.resetAt': '将在 {time} 重置',
'usage.card.freeTrial': '免费试用',
'usage.card.bonus': '奖励',
'usage.card.expires': '到期: {time}',
'usage.doubleClickToRefresh': '双击刷新该提供商用量',
'usage.refreshingProvider': '正在刷新 {name} 用量...',
'usage.group.expandAll': '展开所有卡片',
'usage.group.collapseAll': '折叠所有卡片',
'usage.failedToLoad': '加载失败',
'usage.loadFailed': '获取支持的提供商列表失败',
'usage.resetInfo': '将在 {time} 后重置',
'usage.weeklyLimit': '每周限制',
'usage.time.days': '{days}天{hours}小时',
'usage.time.hours': '{hours}小时{minutes}分',
'usage.time.minutes': '{minutes}分钟',
'usage.time.soon': '即将',
// Logs
'logs.title': '实时日志',
@ -1287,11 +1298,22 @@ const translations = {
'usage.card.status.healthy': 'Healthy',
'usage.card.status.unhealthy': 'Abnormal',
'usage.card.totalUsage': 'Total Usage',
'usage.card.resetAt': 'Resets at {time}',
'usage.card.freeTrial': 'Free Trial',
'usage.card.bonus': 'Bonus',
'usage.card.expires': 'Expires: {time}',
'usage.doubleClickToRefresh': 'Double click to refresh this provider',
'usage.refreshingProvider': 'Refreshing {name} usage...',
'usage.group.expandAll': 'Expand All Cards',
'usage.group.collapseAll': 'Collapse All Cards',
'usage.failedToLoad': 'Failed to load',
'usage.loadFailed': 'Failed to load supported providers',
'usage.resetInfo': 'Resets in {time}',
'usage.weeklyLimit': 'Weekly Limit',
'usage.time.days': '{days}d {hours}h',
'usage.time.hours': '{hours}h {minutes}m',
'usage.time.minutes': '{minutes}m',
'usage.time.soon': 'Soon',
// Logs
'logs.title': 'Real-time Logs',

View file

@ -42,11 +42,19 @@ async function loadSupportedProviders() {
const tag = document.createElement('span');
tag.className = 'provider-tag';
tag.textContent = getProviderDisplayName(provider);
tag.title = t('usage.doubleClickToRefresh') || '双击刷新该提供商用量';
tag.setAttribute('data-i18n-title', 'usage.doubleClickToRefresh');
// 添加双击事件
tag.addEventListener('dblclick', () => {
refreshProviderUsage(provider);
});
listEl.appendChild(tag);
});
} catch (error) {
console.error('获取支持的提供商列表失败:', error);
listEl.innerHTML = '<span class="error-text">Failed to load</span>';
listEl.innerHTML = `<span class="error-text" data-i18n="usage.failedToLoad">${t('usage.failedToLoad')}</span>`;
}
}
@ -105,7 +113,7 @@ export async function loadUsage() {
errorEl.style.display = 'block';
const errorMsgEl = document.getElementById('usageErrorMessage');
if (errorMsgEl) {
errorMsgEl.textContent = error.message || t('usage.title') + '失败';
errorMsgEl.textContent = error.message || (t('usage.title') + t('common.refresh.failed'));
}
}
}
@ -164,7 +172,7 @@ export async function refreshUsage() {
errorEl.style.display = 'block';
const errorMsgEl = document.getElementById('usageErrorMessage');
if (errorMsgEl) {
errorMsgEl.textContent = error.message || t('usage.title') + '失败';
errorMsgEl.textContent = error.message || (t('usage.title') + t('common.refresh.failed'));
}
}
@ -203,7 +211,7 @@ function renderUsageData(data, container) {
const validInstances = [];
for (const instance of providerData.instances) {
// 过滤掉服务实例未初始化的
if (instance.error === '服务实例未初始化') {
if (instance.error === '服务实例未初始化' || instance.error === 'Service instance not initialized') {
continue;
}
// 过滤掉已禁用的提供商
@ -235,6 +243,51 @@ function renderUsageData(data, container) {
}
}
/**
* 刷新特定提供商类型的用量数据
* @param {string} providerType - 提供商类型
*/
export async function refreshProviderUsage(providerType) {
const loadingEl = document.getElementById('usageLoading');
const refreshBtn = document.getElementById('refreshUsageBtn');
const contentEl = document.getElementById('usageContent');
// 显示加载状态
if (loadingEl) loadingEl.style.display = 'block';
if (refreshBtn) refreshBtn.disabled = true;
try {
const providerName = getProviderDisplayName(providerType);
showToast(t('common.info'), t('usage.refreshingProvider', { name: providerName }), 'info');
// 调用按提供商刷新的 API
const response = await fetch(`/api/usage/${providerType}?refresh=true`, {
method: 'GET',
headers: getAuthHeaders()
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const providerData = await response.json();
// 获取当前完整数据并更新其中一个提供商的数据
// 注意:这里为了保持页面一致性,我们重新获取一次完整数据(走缓存)来重新渲染
// 或者手动在当前 DOM 中更新该提供商的部分。
// 为了简单可靠,我们重新 loadUsage(),它会读取刚刚更新过的后端缓存
await loadUsage();
showToast(t('common.success'), t('common.refresh.success'), 'success');
} catch (error) {
console.error(`刷新提供商 ${providerType} 失败:`, error);
showToast(t('common.error'), t('common.refresh.failed') + ': ' + error.message, 'error');
} finally {
if (loadingEl) loadingEl.style.display = 'none';
if (refreshBtn) refreshBtn.disabled = false;
}
}
/**
* 创建提供商分组容器
* @param {string} providerType - 提供商类型
@ -349,6 +402,10 @@ function createInstanceUsageCard(instance, providerType) {
// 显示名称:优先自定义名称,其次 uuid
const displayName = instance.name || instance.uuid;
const displayUsageText = totalUsage.isCodex
? `${totalUsage.percent.toFixed(1)}%`
: `${formatNumber(totalUsage.used)} / ${formatNumber(totalUsage.limit)}`;
collapsedSummary.innerHTML = `
<div class="collapsed-summary-row collapsed-summary-name-row">
@ -362,8 +419,8 @@ function createInstanceUsageCard(instance, providerType) {
<div class="progress-fill" style="width: ${totalUsage.percent}%"></div>
</div>
<span class="collapsed-percent">${totalUsage.percent.toFixed(1)}%</span>
<span class="collapsed-usage-text">${formatNumber(totalUsage.used)} / ${formatNumber(totalUsage.limit)}</span>
` : (instance.error ? `<span class="collapsed-error">${t('common.error')}</span>` : '')}
<span class="collapsed-usage-text">${displayUsageText}</span>
` : (instance.error ? `<span class="collapsed-error" data-i18n="common.error">${t('common.error')}</span>` : '')}
</div>
`;
@ -459,18 +516,43 @@ function renderUsageDetails(usage) {
const progressClass = totalUsage.percent >= 90 ? 'danger' : (totalUsage.percent >= 70 ? 'warning' : 'normal');
// 提取第一个有重置时间的条目(通常是总配额)
let resetTimeHTML = '';
if (totalUsage.isCodex && totalUsage.resetAfterSeconds !== undefined) {
const resetTimeText = formatTimeRemaining(totalUsage.resetAfterSeconds);
resetTimeHTML = `
<div class="total-reset-info" data-i18n="usage.resetInfo" data-i18n-params='{"time":"${resetTimeText}"}'>
<i class="fas fa-history"></i> ${t('usage.resetInfo', { time: resetTimeText })}
</div>
`;
} else {
const resetTimeEntry = usage.usageBreakdown.find(b => b.resetTime && b.resetTime !== '--');
resetTimeHTML = resetTimeEntry ? `
<div class="total-reset-info" data-i18n="usage.card.resetAt" data-i18n-params='{"time":"${resetTimeEntry.resetTime}"}'>
<i class="fas fa-history"></i> ${t('usage.card.resetAt', { time: resetTimeEntry.resetTime })}
</div>
` : '';
}
const displayValue = totalUsage.isCodex
? `${totalUsage.percent.toFixed(1)}%`
: `${formatNumber(totalUsage.used)} / ${formatNumber(totalUsage.limit)}`;
totalSection.innerHTML = `
<div class="total-usage-header">
<span class="total-label">
<i class="fas fa-chart-pie"></i>
<span data-i18n="usage.card.totalUsage">${t('usage.card.totalUsage')}</span>
</span>
<span class="total-value">${formatNumber(totalUsage.used)} / ${formatNumber(totalUsage.limit)}</span>
<span class="total-value">${displayValue}</span>
</div>
<div class="progress-bar ${progressClass}">
<div class="progress-fill" style="width: ${totalUsage.percent}%"></div>
</div>
<div class="total-percent">${totalUsage.percent.toFixed(2)}%</div>
<div class="total-footer">
<div class="total-percent">${totalUsage.percent.toFixed(2)}%</div>
${resetTimeHTML}
</div>
`;
container.appendChild(totalSection);
@ -522,6 +604,19 @@ function createUsageBreakdownHTML(breakdown) {
</div>
`;
// 如果有重置时间,则显示
if (breakdown.resetTime && breakdown.resetTime !== '--') {
const resetText = t('usage.card.resetAt', { time: breakdown.resetTime });
html += `
<div class="extra-usage-info reset-time">
<span class="extra-label">
<i class="fas fa-history"></i>
<span data-i18n="usage.card.resetAt" data-i18n-params='${JSON.stringify({ time: breakdown.resetTime })}'>${resetText}</span>
</span>
</div>
`;
}
// 免费试用信息
if (breakdown.freeTrial && breakdown.freeTrial.status === 'ACTIVE') {
html += `
@ -559,52 +654,28 @@ function createUsageBreakdownHTML(breakdown) {
*/
function createCodexUsageBreakdownHTML(breakdown) {
const rl = breakdown.rateLimit;
const primary = rl.primary_window;
const secondary = rl.secondary_window;
const primaryPercent = primary.used_percent || 0;
const primaryProgressClass = primaryPercent >= 90 ? 'danger' : (primaryPercent >= 70 ? 'warning' : 'normal');
const primaryLimitHours = Math.round(primary.limit_window_seconds / 3600);
const primaryResetText = formatTimeRemaining(primary.reset_after_seconds);
if (!secondary) return '';
let html = `
const secondaryPercent = secondary.used_percent || 0;
const secondaryProgressClass = secondaryPercent >= 90 ? 'danger' : (secondaryPercent >= 70 ? 'warning' : 'normal');
const secondaryResetText = formatTimeRemaining(secondary.reset_after_seconds);
return `
<div class="breakdown-item-compact codex-usage-item">
<div class="breakdown-header-compact">
<span class="breakdown-name"><i class="fas fa-clock"></i> ${primaryLimitHours} </span>
<span class="breakdown-usage">${primaryPercent}%</span>
<span class="breakdown-name" data-i18n="usage.weeklyLimit"><i class="fas fa-calendar-alt"></i> ${t('usage.weeklyLimit')}</span>
<span class="breakdown-usage">${secondaryPercent}%</span>
</div>
<div class="progress-bar-small ${primaryProgressClass}">
<div class="progress-fill" style="width: ${primaryPercent}%"></div>
<div class="progress-bar-small ${secondaryProgressClass}">
<div class="progress-fill" style="width: ${secondaryPercent}%"></div>
</div>
<div class="codex-reset-info">
<i class="fas fa-history"></i> ${primaryResetText}
<div class="codex-reset-info" data-i18n="usage.resetInfo" data-i18n-params='{"time":"${secondaryResetText}"}'>
<i class="fas fa-history"></i> ${t('usage.resetInfo', { time: secondaryResetText })}
</div>
</div>
`;
if (secondary) {
const secondaryPercent = secondary.used_percent || 0;
const secondaryProgressClass = secondaryPercent >= 90 ? 'danger' : (secondaryPercent >= 70 ? 'warning' : 'normal');
const secondaryResetText = formatTimeRemaining(secondary.reset_after_seconds);
html += `
<div class="codex-secondary-usage">
<div class="breakdown-header-compact">
<span class="breakdown-name"><i class="fas fa-calendar-alt"></i> </span>
<span class="breakdown-usage">${secondaryPercent}%</span>
</div>
<div class="progress-bar-small ${secondaryProgressClass}">
<div class="progress-fill" style="width: ${secondaryPercent}%"></div>
</div>
<div class="codex-reset-info">
<i class="fas fa-history"></i> ${secondaryResetText}
</div>
</div>
`;
}
html += '</div>';
return html;
}
/**
@ -613,15 +684,15 @@ function createCodexUsageBreakdownHTML(breakdown) {
* @returns {string} 格式化后的时间
*/
function formatTimeRemaining(seconds) {
if (seconds <= 0) return '即将';
if (seconds <= 0) return t('usage.time.soon');
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) return `${days}${hours}小时`;
if (hours > 0) return `${hours}小时${minutes}`;
return `${minutes}分钟`;
if (days > 0) return t('usage.time.days', { days, hours });
if (hours > 0) return t('usage.time.hours', { hours, minutes });
return t('usage.time.minutes', { minutes });
}
/**
@ -634,6 +705,30 @@ function calculateTotalUsage(usageBreakdown) {
return { hasData: false, used: 0, limit: 0, percent: 0 };
}
// 特殊处理 Codex
const codexEntry = usageBreakdown.find(b => b.rateLimit && b.rateLimit.secondary_window);
if (codexEntry) {
const secondary = codexEntry.rateLimit.secondary_window;
const secondaryPercent = secondary.used_percent || 0;
// 只有当周限制达到 100% 时,总用量才显示 100%
// 否则按正常逻辑计算(或者这里可以理解为非 100% 时不改变原有的总用量逻辑,
// 但根据用户反馈Codex 应该主要关注周限制)
// 重新审视需求达到周限制时总用量直接100%,重置时间设置为周限制时间
if (secondaryPercent >= 100) {
return {
hasData: true,
used: 100,
limit: 100,
percent: 100,
isCodex: true,
resetAfterSeconds: secondary.reset_after_seconds
};
}
// 如果未达到 100%,则继续执行下面的常规计算逻辑
}
let totalUsed = 0;
let totalLimit = 0;

View file

@ -43,16 +43,6 @@
gap: 0.5rem;
}
.provider-tag {
background: var(--bg-primary);
border: 1px solid var(--border-color);
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
color: var(--text-primary);
font-weight: 500;
}
.loading-inline {
display: inline-flex;
align-items: center;
@ -315,6 +305,28 @@
.total-label { font-size: 0.8rem; font-weight: 600; color: var(--text-primary); display: flex; align-items: center; gap: 0.375rem; }
.total-value { font-size: 0.75rem; font-weight: 600; color: var(--text-secondary); font-family: monospace; }
.total-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 0.5rem;
}
.total-percent {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-secondary);
}
.total-reset-info {
font-size: 0.65rem;
color: var(--text-tertiary);
font-style: italic;
display: flex;
align-items: center;
gap: 0.25rem;
}
.reset-info-compact { background: var(--bg-secondary); padding: 0.5rem 0.75rem; border-radius: 0.375rem; border: 1px solid var(--border-color); }
.reset-info-row { display: flex; justify-content: space-between; align-items: center; padding: 0.25rem 0; }
.reset-info-row:first-child { border-bottom: 1px solid var(--border-color); padding-bottom: 0.375rem; margin-bottom: 0.25rem; }
@ -408,11 +420,6 @@
[data-theme="dark"] .page-btn { background: var(--bg-primary); border-color: var(--border-color); color: var(--text-primary); }
[data-theme="dark"] .page-jump-input { background: var(--bg-primary); border-color: var(--border-color); color: var(--text-primary); }
/* Codex 专用样式 */
.codex-usage-item {
border-left: 3px solid var(--primary-color);
}
.codex-reset-info {
font-size: 0.65rem;
color: var(--text-tertiary);