AIClient-2-API/static/app/usage-manager.js
hex2077 05df61df74 feat: 添加 TLS Sidecar 支持并重构前端提供商配置系统
- 新增 TLS Sidecar 功能文档,支持绕过 Grok 等服务的 Cloudflare 403 封锁
- 重构前端提供商配置管理,实现动态配置缓存和统一数据源
- 升级配置文件管理界面 UI,优化信息展示和交互体验
- 改进 Claude Kiro 工具调用流式响应,实时推送 content_block 事件
- 修复 Codex 配额重置时间格式问题

主要变更:
- README 文档新增 TLS Sidecar 配置说明和使用指南
- 新增 tutorial-manager.js 模块,动态渲染 OAuth 授权路径
- routing-examples.js 支持动态生成路径路由示例卡片
- upload-config-manager.js 重构列表项布局,支持节点关联信息展示
- config-manager、models-manager、usage-manager 统一使用提供商配置缓存
- i18n 新增多语言翻译键,更新 Gemini 模型版本信息
2026-03-03 20:05:51 +08:00

944 lines
No EOL
35 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 用量管理模块
import { showToast } from './utils.js';
import { getAuthHeaders } from './auth.js';
import { t, getCurrentLanguage } from './i18n.js';
/**
* 不支持显示用量数据的提供商列表
* 这些提供商只显示模型名称和重置时间,不显示用量数字和进度条
*/
const PROVIDERS_WITHOUT_USAGE_DISPLAY = [
'gemini-antigravity'
];
// 提供商配置缓存
let currentProviderConfigs = null;
/**
* 更新提供商配置
* @param {Array} configs - 提供商配置列表
*/
export function updateUsageProviderConfigs(configs) {
currentProviderConfigs = configs;
// 重新触发列表加载,以应用最新的可见性过滤、名称和图标
loadSupportedProviders();
loadUsage();
}
/**
* 检查提供商是否支持显示用量
* @param {string} providerType - 提供商类型
* @returns {boolean} 是否支持显示用量
*/
function shouldShowUsage(providerType) {
return !PROVIDERS_WITHOUT_USAGE_DISPLAY.includes(providerType);
}
/**
* 初始化用量管理功能
*/
export function initUsageManager() {
const refreshBtn = document.getElementById('refreshUsageBtn');
if (refreshBtn) {
refreshBtn.addEventListener('click', refreshUsage);
}
// 初始化时自动加载缓存数据
loadUsage();
loadSupportedProviders();
}
/**
* 加载支持用量查询的提供商列表
*/
async function loadSupportedProviders() {
const listEl = document.getElementById('supportedProvidersList');
if (!listEl) return;
try {
const response = await fetch('/api/usage/supported-providers', {
method: 'GET',
headers: getAuthHeaders()
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const providers = await response.json();
listEl.innerHTML = '';
// 按照 currentProviderConfigs 的顺序渲染,确保顺序一致性
const displayOrder = currentProviderConfigs
? currentProviderConfigs.map(c => c.id)
: providers;
displayOrder.forEach(providerId => {
// 必须是后端支持且前端配置可见的提供商
const isSupported = providers.includes(providerId);
if (!isSupported) return;
if (currentProviderConfigs) {
const config = currentProviderConfigs.find(c => c.id === providerId);
if (config && config.visible === false) return;
}
const tag = document.createElement('span');
tag.className = 'provider-tag';
tag.textContent = getProviderDisplayName(providerId);
tag.title = t('usage.doubleClickToRefresh') || '双击刷新该提供商用量';
tag.setAttribute('data-i18n-title', 'usage.doubleClickToRefresh');
// 添加双击事件
tag.addEventListener('dblclick', () => {
refreshProviderUsage(providerId);
});
listEl.appendChild(tag);
});
} catch (error) {
console.error('获取支持的提供商列表失败:', error);
listEl.innerHTML = `<span class="error-text" data-i18n="usage.failedToLoad">${t('usage.failedToLoad')}</span>`;
}
}
/**
* 加载用量数据(优先从缓存读取)
*/
export async function loadUsage() {
const loadingEl = document.getElementById('usageLoading');
const errorEl = document.getElementById('usageError');
const contentEl = document.getElementById('usageContent');
const emptyEl = document.getElementById('usageEmpty');
const lastUpdateEl = document.getElementById('usageLastUpdate');
// 显示加载状态
if (loadingEl) loadingEl.style.display = 'block';
if (errorEl) errorEl.style.display = 'none';
if (emptyEl) emptyEl.style.display = 'none';
try {
// 不带 refresh 参数,优先读取缓存
const response = await fetch('/api/usage', {
method: 'GET',
headers: getAuthHeaders()
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
// 隐藏加载状态
if (loadingEl) loadingEl.style.display = 'none';
// 渲染用量数据
renderUsageData(data, contentEl);
// 更新服务端系统时间
if (data.serverTime) {
const serverTimeEl = document.getElementById('serverTimeValue');
if (serverTimeEl) {
serverTimeEl.textContent = new Date(data.serverTime).toLocaleString(getCurrentLanguage());
}
}
// 更新最后更新时间
if (lastUpdateEl) {
const timeStr = new Date(data.timestamp || Date.now()).toLocaleString(getCurrentLanguage());
if (data.fromCache && data.timestamp) {
lastUpdateEl.textContent = t('usage.lastUpdateCache', { time: timeStr });
lastUpdateEl.setAttribute('data-i18n', 'usage.lastUpdateCache');
lastUpdateEl.setAttribute('data-i18n-params', JSON.stringify({ time: timeStr }));
} else {
lastUpdateEl.textContent = t('usage.lastUpdate', { time: timeStr });
lastUpdateEl.setAttribute('data-i18n', 'usage.lastUpdate');
lastUpdateEl.setAttribute('data-i18n-params', JSON.stringify({ time: timeStr }));
}
}
} catch (error) {
console.error('获取用量数据失败:', error);
if (loadingEl) loadingEl.style.display = 'none';
if (errorEl) {
errorEl.style.display = 'block';
const errorMsgEl = document.getElementById('usageErrorMessage');
if (errorMsgEl) {
errorMsgEl.textContent = error.message || (t('usage.title') + t('common.refresh.failed'));
}
}
}
}
/**
* 刷新用量数据(强制从服务器获取最新数据)
*/
export async function refreshUsage() {
const loadingEl = document.getElementById('usageLoading');
const errorEl = document.getElementById('usageError');
const contentEl = document.getElementById('usageContent');
const emptyEl = document.getElementById('usageEmpty');
const lastUpdateEl = document.getElementById('usageLastUpdate');
const refreshBtn = document.getElementById('refreshUsageBtn');
// 显示加载状态
if (loadingEl) loadingEl.style.display = 'block';
if (errorEl) errorEl.style.display = 'none';
if (emptyEl) emptyEl.style.display = 'none';
if (refreshBtn) refreshBtn.disabled = true;
try {
// 带 refresh=true 参数,强制刷新
const response = await fetch('/api/usage?refresh=true', {
method: 'GET',
headers: getAuthHeaders()
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
// 隐藏加载状态
if (loadingEl) loadingEl.style.display = 'none';
// 渲染用量数据
renderUsageData(data, contentEl);
// 更新服务端系统时间
if (data.serverTime) {
const serverTimeEl = document.getElementById('serverTimeValue');
if (serverTimeEl) {
serverTimeEl.textContent = new Date(data.serverTime).toLocaleString(getCurrentLanguage());
}
}
// 更新最后更新时间
if (lastUpdateEl) {
const timeStr = new Date().toLocaleString(getCurrentLanguage());
lastUpdateEl.textContent = t('usage.lastUpdate', { time: timeStr });
lastUpdateEl.setAttribute('data-i18n', 'usage.lastUpdate');
lastUpdateEl.setAttribute('data-i18n-params', JSON.stringify({ time: timeStr }));
}
showToast(t('common.success'), t('common.refresh.success'), 'success');
} catch (error) {
console.error('获取用量数据失败:', error);
if (loadingEl) loadingEl.style.display = 'none';
if (errorEl) {
errorEl.style.display = 'block';
const errorMsgEl = document.getElementById('usageErrorMessage');
if (errorMsgEl) {
errorMsgEl.textContent = error.message || (t('usage.title') + t('common.refresh.failed'));
}
}
showToast(t('common.error'), t('common.refresh.failed') + ': ' + error.message, 'error');
} finally {
if (refreshBtn) refreshBtn.disabled = false;
}
}
/**
* 渲染用量数据
* @param {Object} data - 用量数据
* @param {HTMLElement} container - 容器元素
*/
function renderUsageData(data, container) {
if (!container) return;
// 清空容器
container.innerHTML = '';
if (!data || !data.providers || Object.keys(data.providers).length === 0) {
container.innerHTML = `
<div class="usage-empty">
<i class="fas fa-chart-bar"></i>
<p data-i18n="usage.noData">${t('usage.noData')}</p>
</div>
`;
return;
}
// 按提供商分组收集已初始化且未禁用的实例
const groupedInstances = {};
for (const [providerType, providerData] of Object.entries(data.providers)) {
// 如果配置了不可见,则跳过
if (currentProviderConfigs) {
const config = currentProviderConfigs.find(c => c.id === providerType);
if (config && config.visible === false) continue;
}
if (providerData.instances && providerData.instances.length > 0) {
const validInstances = [];
for (const instance of providerData.instances) {
// 过滤掉服务实例未初始化的
if (instance.error === '服务实例未初始化' || instance.error === 'Service instance not initialized') {
continue;
}
// 过滤掉已禁用的提供商
if (instance.isDisabled) {
continue;
}
validInstances.push(instance);
}
if (validInstances.length > 0) {
groupedInstances[providerType] = validInstances;
}
}
}
if (Object.keys(groupedInstances).length === 0) {
container.innerHTML = `
<div class="usage-empty">
<i class="fas fa-chart-bar"></i>
<p data-i18n="usage.noInstances">${t('usage.noInstances')}</p>
</div>
`;
return;
}
// 按提供商分组渲染,使用统一的显示顺序
const displayOrder = currentProviderConfigs
? currentProviderConfigs.map(c => c.id)
: Object.keys(groupedInstances);
displayOrder.forEach(providerType => {
const instances = groupedInstances[providerType];
if (instances && instances.length > 0) {
const groupContainer = createProviderGroup(providerType, instances);
container.appendChild(groupContainer);
}
});
}
/**
* 刷新特定提供商类型的用量数据
* @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 - 提供商类型
* @param {Array} instances - 实例数组
* @returns {HTMLElement} 分组容器元素
*/
function createProviderGroup(providerType, instances) {
const groupContainer = document.createElement('div');
groupContainer.className = 'usage-provider-group collapsed';
const providerDisplayName = getProviderDisplayName(providerType);
const providerIcon = getProviderIcon(providerType);
const instanceCount = instances.length;
const successCount = instances.filter(i => i.success).length;
// 分组头部(可点击折叠)
const header = document.createElement('div');
header.className = 'usage-group-header';
header.innerHTML = `
<div class="usage-group-title">
<i class="fas fa-chevron-right toggle-icon"></i>
<i class="${providerIcon} provider-icon"></i>
<span class="provider-name">${providerDisplayName}</span>
<span class="instance-count" data-i18n="usage.group.instances" data-i18n-params='{"count":"${instanceCount}"}'>${t('usage.group.instances', { count: instanceCount })}</span>
<span class="success-count ${successCount === instanceCount ? 'all-success' : ''}" data-i18n="usage.group.success" data-i18n-params='{"count":"${successCount}","total":"${instanceCount}"}'>${t('usage.group.success', { count: successCount, total: instanceCount })}</span>
</div>
<div class="usage-group-actions">
<button class="btn-toggle-cards" title="${t('usage.group.expandAll')}">
<i class="fas fa-expand-alt"></i>
</button>
</div>
`;
// 点击头部切换分组折叠状态
const titleDiv = header.querySelector('.usage-group-title');
titleDiv.addEventListener('click', () => {
groupContainer.classList.toggle('collapsed');
});
groupContainer.appendChild(header);
// 展开/折叠所有卡片按钮事件
const toggleCardsBtn = header.querySelector('.btn-toggle-cards');
toggleCardsBtn.addEventListener('click', (e) => {
e.stopPropagation(); // 阻止事件冒泡到分组头部
const cards = groupContainer.querySelectorAll('.usage-instance-card');
const allCollapsed = Array.from(cards).every(card => card.classList.contains('collapsed'));
// 如果全部折叠,则全部展开;否则全部折叠
cards.forEach(card => {
if (allCollapsed) {
card.classList.remove('collapsed');
} else {
card.classList.add('collapsed');
}
});
// 更新按钮图标和提示文本
const icon = toggleCardsBtn.querySelector('i');
if (allCollapsed) {
icon.className = 'fas fa-compress-alt';
toggleCardsBtn.title = t('usage.group.collapseAll');
} else {
icon.className = 'fas fa-expand-alt';
toggleCardsBtn.title = t('usage.group.expandAll');
}
});
// 分组内容(卡片网格)
const content = document.createElement('div');
content.className = 'usage-group-content';
const gridContainer = document.createElement('div');
gridContainer.className = 'usage-cards-grid';
for (const instance of instances) {
const instanceCard = createInstanceUsageCard(instance, providerType);
gridContainer.appendChild(instanceCard);
}
content.appendChild(gridContainer);
groupContainer.appendChild(content);
return groupContainer;
}
/**
* 创建实例用量卡片
* @param {Object} instance - 实例数据
* @param {string} providerType - 提供商类型
* @returns {HTMLElement} 卡片元素
*/
function createInstanceUsageCard(instance, providerType) {
const card = document.createElement('div');
card.className = `usage-instance-card ${instance.success ? 'success' : 'error'} collapsed`;
const providerDisplayName = getProviderDisplayName(providerType);
const providerIcon = getProviderIcon(providerType);
// 检查是否应该显示用量信息
const showUsage = shouldShowUsage(providerType);
// 计算总用量(用于折叠摘要显示)
const totalUsage = instance.usage ? calculateTotalUsage(instance.usage.usageBreakdown) : { hasData: false, percent: 0 };
const progressClass = totalUsage.percent >= 90 ? 'danger' : (totalUsage.percent >= 70 ? 'warning' : 'normal');
// 折叠摘要 - 两行显示
const collapsedSummary = document.createElement('div');
collapsedSummary.className = 'usage-card-collapsed-summary';
const statusIcon = instance.success
? '<i class="fas fa-check-circle status-success"></i>'
: '<i class="fas fa-times-circle status-error"></i>';
// 显示名称:优先自定义名称,其次 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">
<i class="fas fa-chevron-right usage-toggle-icon"></i>
<span class="collapsed-name" title="${displayName}">${displayName}</span>
${statusIcon}
</div>
${showUsage ? `
<div class="collapsed-summary-row collapsed-summary-usage-row">
${totalUsage.hasData ? `
<div class="collapsed-progress-bar ${progressClass}">
<div class="progress-fill" style="width: ${totalUsage.percent}%"></div>
</div>
<span class="collapsed-percent">${totalUsage.percent.toFixed(1)}%</span>
<span class="collapsed-usage-text">${displayUsageText}</span>
` : (instance.error ? `<span class="collapsed-error" data-i18n="common.error">${t('common.error')}</span>` : '')}
</div>
` : ''}
`;
// 点击折叠摘要切换展开状态
collapsedSummary.addEventListener('click', (e) => {
e.stopPropagation();
card.classList.toggle('collapsed');
});
card.appendChild(collapsedSummary);
// 展开内容区域
const expandedContent = document.createElement('div');
expandedContent.className = 'usage-card-expanded-content';
// 实例头部 - 整合用户信息
const header = document.createElement('div');
header.className = 'usage-instance-header';
const healthBadge = instance.isDisabled
? `<span class="badge badge-disabled" data-i18n="usage.card.status.disabled">${t('usage.card.status.disabled')}</span>`
: (instance.isHealthy
? `<span class="badge badge-healthy" data-i18n="usage.card.status.healthy">${t('usage.card.status.healthy')}</span>`
: `<span class="badge badge-unhealthy" data-i18n="usage.card.status.unhealthy">${t('usage.card.status.unhealthy')}</span>`);
// 获取用户邮箱和订阅信息
const userEmail = instance.usage?.user?.email || '';
const subscriptionTitle = instance.usage?.subscription?.title || '';
// 用户信息行
const userInfoHTML = userEmail ? `
<div class="instance-user-info">
<span class="user-email" title="${userEmail}"><i class="fas fa-envelope"></i> ${userEmail}</span>
${subscriptionTitle ? `<span class="user-subscription">${subscriptionTitle}</span>` : ''}
</div>
` : '';
header.innerHTML = `
<div class="instance-header-top">
<div class="instance-provider-type">
<i class="${providerIcon}"></i>
<span>${providerDisplayName}</span>
</div>
<div class="instance-status-badges">
${statusIcon}
${healthBadge}
</div>
</div>
<div class="instance-name">
<span class="instance-name-text" title="${instance.name || instance.uuid}">${instance.name || instance.uuid}</span>
</div>
${userInfoHTML}
`;
expandedContent.appendChild(header);
// 实例内容 - 只显示用量和到期时间
const content = document.createElement('div');
content.className = 'usage-instance-content';
if (instance.error) {
content.innerHTML = `
<div class="usage-error-message">
<i class="fas fa-exclamation-triangle"></i>
<span>${instance.error}</span>
</div>
`;
} else if (instance.usage) {
content.appendChild(renderUsageDetails(instance.usage, providerType));
}
expandedContent.appendChild(content);
card.appendChild(expandedContent);
return card;
}
/**
* 渲染用量详情 - 显示总用量、用量明细和到期时间
* @param {Object} usage - 用量数据
* @param {string} providerType - 提供商类型
* @returns {HTMLElement} 详情元素
*/
function renderUsageDetails(usage, providerType) {
const container = document.createElement('div');
container.className = 'usage-details';
// 检查是否应该显示用量信息
const showUsage = shouldShowUsage(providerType);
// 计算总用量
const totalUsage = calculateTotalUsage(usage.usageBreakdown);
// 总用量进度条(不支持显示用量的提供商不显示)
if (totalUsage.hasData && showUsage) {
const totalSection = document.createElement('div');
totalSection.className = 'usage-section total-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 !== '--');
if (resetTimeEntry) {
const formattedResetTime = formatDate(resetTimeEntry.resetTime);
resetTimeHTML = `
<div class="total-reset-info" data-i18n="usage.card.resetAt" data-i18n-params='{"time":"${formattedResetTime}"}'>
<i class="fas fa-history"></i> ${t('usage.card.resetAt', { time: formattedResetTime })}
</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">${displayValue}</span>
</div>
<div class="progress-bar ${progressClass}">
<div class="progress-fill" style="width: ${totalUsage.percent}%"></div>
</div>
<div class="total-footer">
<div class="total-percent">${totalUsage.percent.toFixed(2)}%</div>
${resetTimeHTML}
</div>
`;
container.appendChild(totalSection);
}
// 用量明细(包含免费试用和奖励信息)
if (usage.usageBreakdown && usage.usageBreakdown.length > 0) {
const breakdownSection = document.createElement('div');
breakdownSection.className = 'usage-section usage-breakdown-compact';
let breakdownHTML = '';
for (const breakdown of usage.usageBreakdown) {
breakdownHTML += createUsageBreakdownHTML(breakdown, providerType);
}
breakdownSection.innerHTML = breakdownHTML;
container.appendChild(breakdownSection);
}
return container;
}
/**
* 创建用量明细 HTML紧凑版
* @param {Object} breakdown - 用量明细数据
* @param {string} providerType - 提供商类型
* @returns {string} HTML 字符串
*/
function createUsageBreakdownHTML(breakdown, providerType) {
// 特殊处理 Codex
if (breakdown.rateLimit && breakdown.rateLimit.primary_window) {
return createCodexUsageBreakdownHTML(breakdown);
}
// 检查是否应该显示用量信息
const showUsage = shouldShowUsage(providerType);
const usagePercent = breakdown.usageLimit > 0
? Math.min(100, (breakdown.currentUsage / breakdown.usageLimit) * 100)
: 0;
const progressClass = usagePercent >= 90 ? 'danger' : (usagePercent >= 70 ? 'warning' : 'normal');
let html = `
<div class="breakdown-item-compact">
<div class="breakdown-header-compact">
<span class="breakdown-name">${breakdown.displayName || breakdown.resourceType}</span>
${showUsage ? `<span class="breakdown-usage">${formatNumber(breakdown.currentUsage)} / ${formatNumber(breakdown.usageLimit)}</span>` : ''}
</div>
${showUsage ? `
<div class="progress-bar-small ${progressClass}">
<div class="progress-fill" style="width: ${usagePercent}%"></div>
</div>
` : ''}
`;
// 如果有重置时间,则显示
if (breakdown.resetTime && breakdown.resetTime !== '--') {
const formattedResetTime = formatDate(breakdown.resetTime);
const resetText = t('usage.card.resetAt', { time: formattedResetTime });
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: formattedResetTime })}'>${resetText}</span>
</span>
</div>
`;
}
// 免费试用信息
if (breakdown.freeTrial && breakdown.freeTrial.status === 'ACTIVE') {
html += `
<div class="extra-usage-info free-trial">
<span class="extra-label"><i class="fas fa-gift"></i> <span data-i18n="usage.card.freeTrial">${t('usage.card.freeTrial')}</span></span>
<span class="extra-value">${formatNumber(breakdown.freeTrial.currentUsage)} / ${formatNumber(breakdown.freeTrial.usageLimit)}</span>
<span class="extra-expires" data-i18n="usage.card.expires" data-i18n-params='{"time":"${formatDate(breakdown.freeTrial.expiresAt)}"}'>${t('usage.card.expires', { time: formatDate(breakdown.freeTrial.expiresAt) })}</span>
</div>
`;
}
// 奖励信息
if (breakdown.bonuses && breakdown.bonuses.length > 0) {
for (const bonus of breakdown.bonuses) {
if (bonus.status === 'ACTIVE') {
html += `
<div class="extra-usage-info bonus">
<span class="extra-label"><i class="fas fa-star"></i> ${bonus.displayName || bonus.code}</span>
<span class="extra-value">${formatNumber(bonus.currentUsage)} / ${formatNumber(bonus.usageLimit)}</span>
<span class="extra-expires" data-i18n="usage.card.expires" data-i18n-params='{"time":"${formatDate(bonus.expiresAt)}"}'>${t('usage.card.expires', { time: formatDate(bonus.expiresAt) })}</span>
</div>
`;
}
}
}
html += '</div>';
return html;
}
/**
* 创建 Codex 专用的用量明细 HTML
* @param {Object} breakdown - 包含 rateLimit 的用量明细
* @returns {string} HTML 字符串
*/
function createCodexUsageBreakdownHTML(breakdown) {
const rl = breakdown.rateLimit;
const secondary = rl.secondary_window;
if (!secondary) return '';
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" 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 ${secondaryProgressClass}">
<div class="progress-fill" style="width: ${secondaryPercent}%"></div>
</div>
<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>
`;
}
/**
* 格式化剩余时间
* @param {number} seconds - 秒数
* @returns {string} 格式化后的时间
*/
function formatTimeRemaining(seconds) {
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 t('usage.time.days', { days, hours });
if (hours > 0) return t('usage.time.hours', { hours, minutes });
return t('usage.time.minutes', { minutes });
}
/**
* 计算总用量(包含基础用量、免费试用和奖励)
* @param {Array} usageBreakdown - 用量明细数组
* @returns {Object} 总用量信息
*/
function calculateTotalUsage(usageBreakdown) {
if (!usageBreakdown || usageBreakdown.length === 0) {
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;
for (const breakdown of usageBreakdown) {
// 基础用量
totalUsed += breakdown.currentUsage || 0;
totalLimit += breakdown.usageLimit || 0;
// 免费试用用量
if (breakdown.freeTrial && breakdown.freeTrial.status === 'ACTIVE') {
totalUsed += breakdown.freeTrial.currentUsage || 0;
totalLimit += breakdown.freeTrial.usageLimit || 0;
}
// 奖励用量
if (breakdown.bonuses && breakdown.bonuses.length > 0) {
for (const bonus of breakdown.bonuses) {
if (bonus.status === 'ACTIVE') {
totalUsed += bonus.currentUsage || 0;
totalLimit += bonus.usageLimit || 0;
}
}
}
}
const percent = totalLimit > 0 ? Math.min(100, (totalUsed / totalLimit) * 100) : 0;
return {
hasData: true,
used: totalUsed,
limit: totalLimit,
percent: percent
};
}
/**
* 获取提供商显示名称
* @param {string} providerType - 提供商类型
* @returns {string} 显示名称
*/
function getProviderDisplayName(providerType) {
// 优先从外部传入的配置中获取名称
if (currentProviderConfigs) {
const config = currentProviderConfigs.find(c => c.id === providerType);
if (config && config.name) {
return config.name;
}
}
const names = {
'claude-kiro-oauth': 'Claude Kiro OAuth',
'gemini-cli-oauth': 'Gemini CLI OAuth',
'gemini-antigravity': 'Gemini Antigravity',
'openai-codex-oauth': 'Codex OAuth',
'openai-qwen-oauth': 'Qwen OAuth',
'grok-custom': 'Grok Reverse'
};
return names[providerType] || providerType;
}
/**
* 获取提供商图标
* @param {string} providerType - 提供商类型
* @returns {string} 图标类名
*/
function getProviderIcon(providerType) {
// 优先从外部传入的配置中获取图标
if (currentProviderConfigs) {
const config = currentProviderConfigs.find(c => c.id === providerType);
if (config && config.icon) {
// 如果 icon 已经包含 fa- 则直接使用,否则加上 fas
return config.icon.startsWith('fa-') ? `fas ${config.icon}` : config.icon;
}
}
const icons = {
'claude-kiro-oauth': 'fas fa-robot',
'gemini-cli-oauth': 'fas fa-gem',
'gemini-antigravity': 'fas fa-rocket',
'openai-codex-oauth': 'fas fa-terminal',
'openai-qwen-oauth': 'fas fa-code',
'grok-custom': 'fas fa-brain'
};
return icons[providerType] || 'fas fa-server';
}
/**
* 格式化数字(向上取整保留两位小数)
* @param {number} num - 数字
* @returns {string} 格式化后的数字
*/
function formatNumber(num) {
if (num === null || num === undefined) return '0.00';
// 向上取整到两位小数
const rounded = Math.ceil(num * 100) / 100;
return rounded.toFixed(2);
}
/**
* 格式化日期
* @param {string} dateStr - ISO 日期字符串
* @returns {string} 格式化后的日期
*/
function formatDate(dateStr) {
if (!dateStr) return '--';
try {
const date = new Date(dateStr);
return date.toLocaleString(getCurrentLanguage(), {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
} catch (e) {
return dateStr;
}
}