AIClient-2-API/static/app/usage-manager.js
hex2077 fa8150701f feat(i18n): 添加多语言支持并实现国际化功能
实现中英文双语支持,包括:
1. 添加i18n.js核心模块处理语言切换和翻译
2. 创建语言切换器组件
3. 更新所有UI文本使用翻译键
4. 添加I18N_GUIDE.md文档说明使用方法
5. 修改样式适配语言切换器
6. 添加adm-zip依赖支持配置文件打包下载
7. 更新登录页面支持多语言
8. 重构toast消息显示支持多语言标题
2025-12-20 17:27:30 +08:00

544 lines
No EOL
20 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';
/**
* 初始化用量管理功能
*/
export function initUsageManager() {
const refreshBtn = document.getElementById('refreshUsageBtn');
if (refreshBtn) {
refreshBtn.addEventListener('click', refreshUsage);
}
// 初始化时自动加载缓存数据
loadUsage();
}
/**
* 加载用量数据(优先从缓存读取)
*/
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 (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') + '失败';
}
}
}
}
/**
* 刷新用量数据(强制从服务器获取最新数据)
*/
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 (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') + '失败';
}
}
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 (providerData.instances && providerData.instances.length > 0) {
const validInstances = [];
for (const instance of providerData.instances) {
// 过滤掉服务实例未初始化的
if (instance.error === '服务实例未初始化') {
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;
}
// 按提供商分组渲染
for (const [providerType, instances] of Object.entries(groupedInstances)) {
const groupContainer = createProviderGroup(providerType, instances);
container.appendChild(groupContainer);
}
}
/**
* 创建提供商分组容器
* @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>
`;
// 点击头部切换折叠状态
header.addEventListener('click', () => {
groupContainer.classList.toggle('collapsed');
});
groupContainer.appendChild(header);
// 分组内容(卡片网格)
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'}`;
const providerDisplayName = getProviderDisplayName(providerType);
const providerIcon = getProviderIcon(providerType);
// 实例头部 - 整合用户信息
const header = document.createElement('div');
header.className = 'usage-instance-header';
const statusIcon = instance.success
? '<i class="fas fa-check-circle status-success"></i>'
: '<i class="fas fa-times-circle status-error"></i>';
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}
`;
card.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));
}
card.appendChild(content);
return card;
}
/**
* 渲染用量详情 - 显示总用量、用量明细和到期时间
* @param {Object} usage - 用量数据
* @returns {HTMLElement} 详情元素
*/
function renderUsageDetails(usage) {
const container = document.createElement('div');
container.className = 'usage-details';
// 计算总用量
const totalUsage = calculateTotalUsage(usage.usageBreakdown);
// 总用量进度条
if (totalUsage.hasData) {
const totalSection = document.createElement('div');
totalSection.className = 'usage-section total-usage';
const progressClass = totalUsage.percent >= 90 ? 'danger' : (totalUsage.percent >= 70 ? 'warning' : 'normal');
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>
</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>
`;
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);
}
breakdownSection.innerHTML = breakdownHTML;
container.appendChild(breakdownSection);
}
return container;
}
/**
* 创建用量明细 HTML紧凑版
* @param {Object} breakdown - 用量明细数据
* @returns {string} HTML 字符串
*/
function createUsageBreakdownHTML(breakdown) {
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>
<span class="breakdown-usage">${formatNumber(breakdown.currentUsage)} / ${formatNumber(breakdown.usageLimit)}</span>
</div>
<div class="progress-bar-small ${progressClass}">
<div class="progress-fill" style="width: ${usagePercent}%"></div>
</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;
}
/**
* 计算总用量(包含基础用量、免费试用和奖励)
* @param {Array} usageBreakdown - 用量明细数组
* @returns {Object} 总用量信息
*/
function calculateTotalUsage(usageBreakdown) {
if (!usageBreakdown || usageBreakdown.length === 0) {
return { hasData: false, used: 0, limit: 0, percent: 0 };
}
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) {
const names = {
'claude-kiro-oauth': 'Claude Kiro OAuth',
'gemini-cli-oauth': 'Gemini CLI OAuth',
'gemini-antigravity': 'Gemini Antigravity',
'openai-qwen-oauth': 'Qwen OAuth'
};
return names[providerType] || providerType;
}
/**
* 获取提供商图标
* @param {string} providerType - 提供商类型
* @returns {string} 图标类名
*/
function getProviderIcon(providerType) {
const icons = {
'claude-kiro-oauth': 'fas fa-robot',
'gemini-cli-oauth': 'fas fa-gem',
'gemini-antigravity': 'fas fa-rocket',
'openai-qwen-oauth': 'fas fa-code'
};
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;
}
}