Compare commits

...

5 Commits

Author SHA1 Message Date
Supra4E8C
89099b58ff update README 2025-10-26 14:56:54 +08:00
Supra4E8C
7509a1eddc 更新补全i18n国际化文本 2025-10-26 14:54:07 +08:00
hkfires
e92784f951 feat(ui): add auth file success/failure stats; expand list height 2025-10-21 21:59:08 +08:00
hkfires
d26695da76 feat(ui,keys): granular masking, stats match, legacy format
- Make maskApiKey progressive for short keys to reduce exposure
- When aggregating usage, fall back to masked key to match stored stats
- Support legacy provider config `api-keys` by mapping to `api-key-entries`
- Add scrolling for provider lists >5 and reset styles when empty
- Improves privacy, fixes mismatched stats display, and preserves compatibility
2025-10-21 21:08:09 +08:00
Supra4E8C
8964030ade 尝试修复bug 2025-10-21 12:31:22 +08:00
6 changed files with 287 additions and 126 deletions

View File

@@ -10,7 +10,7 @@ Example URL:
https://remote.router-for.me/
Minimum required version: ≥ 6.0.0
Recommended version: ≥ 6.1.3
Recommended version: ≥ 6.2.32
Since version 6.0.19, the WebUI has been rolled into the main program. You can access it by going to `/management.html` on the external port after firing up the main project.

View File

@@ -9,7 +9,7 @@ https://remote.router-for.me/
最低可用版本 ≥ 6.0.0
推荐版本 ≥ 6.1.3
推荐版本 ≥ 6.2.32
自6.0.19起WebUI已经集成在主程序中 可以通过主项目开启的外部端口的`/management.html`访问

308
app.js
View File

@@ -182,12 +182,12 @@ class CLIProxyManager {
// 如果有完整的连接信息且之前已登录,尝试自动登录
if (savedBase && savedKey && wasLoggedIn) {
try {
console.log('检测到本地连接数据,尝试自动登录...');
console.log(i18n.t('auto_login.title'));
this.showAutoLoginLoading();
await this.attemptAutoLogin(savedBase, savedKey);
return; // 自动登录成功,不显示登录页面
} catch (error) {
console.log('自动登录失败:', error.message);
console.log(`${i18n.t('notification.login_failed')}: ${error.message}`);
// 清除无效的登录状态
localStorage.removeItem('isLoggedIn');
this.hideAutoLoginLoading();
@@ -232,7 +232,7 @@ class CLIProxyManager {
this.hideAutoLoginLoading();
this.showMainPage();
console.log('自动登录成功');
console.log(i18n.t('auto_login.title'));
return true;
} catch (error) {
console.error('自动登录失败:', error);
@@ -807,7 +807,8 @@ class CLIProxyManager {
// 更新按钮提示文本
const toggleBtn = document.getElementById('sidebar-toggle-btn-desktop');
if (toggleBtn) {
toggleBtn.setAttribute('title', isCollapsed ? '展开侧边栏' : '收起侧边栏');
toggleBtn.setAttribute('data-i18n-title', isCollapsed ? 'sidebar.toggle_expand' : 'sidebar.toggle_collapse');
toggleBtn.title = i18n.t(isCollapsed ? 'sidebar.toggle_expand' : 'sidebar.toggle_collapse');
}
}
}
@@ -828,7 +829,8 @@ class CLIProxyManager {
// 更新按钮提示文本
const toggleBtn = document.getElementById('sidebar-toggle-btn-desktop');
if (toggleBtn) {
toggleBtn.setAttribute('title', '展开侧边栏');
toggleBtn.setAttribute('data-i18n-title', 'sidebar.toggle_expand');
toggleBtn.title = i18n.t('sidebar.toggle_expand');
}
}
}
@@ -1420,7 +1422,7 @@ class CLIProxyManager {
// 加载所有数据 - 使用新的 /config 端点一次性获取所有配置
async loadAllData(forceRefresh = false) {
try {
console.log('使用新的 /config 端点加载所有配置...');
console.log(i18n.t('system_info.real_time_data'));
// 使用新的 /config 端点一次性获取所有配置
const config = await this.getConfig(forceRefresh);
@@ -2046,8 +2048,14 @@ class CLIProxyManager {
// 遮蔽API密钥显示
maskApiKey(key) {
if (key.length <= 8) return key;
return key.substring(0, 4) + '...' + key.substring(key.length - 4);
if (key.length > 8) {
return key.substring(0, 4) + '...' + key.substring(key.length - 4);
} else if (key.length > 4) {
return key.substring(0, 2) + '...' + key.substring(key.length - 2);
} else if (key.length > 2) {
return key.substring(0, 1) + '...' + key.substring(key.length - 1);
}
return key;
}
// HTML 转义,防止 XSS
@@ -2198,7 +2206,8 @@ class CLIProxyManager {
const stats = await this.getKeyStats();
container.innerHTML = keys.map((key, index) => {
const keyStats = stats[key] || { success: 0, failure: 0 };
const masked = this.maskApiKey(key);
const keyStats = stats[key] || stats[masked] || { success: 0, failure: 0 };
return `
<div class="key-item">
<div class="item-content">
@@ -2206,10 +2215,10 @@ class CLIProxyManager {
<div class="item-value">${this.maskApiKey(key)}</div>
<div class="item-stats">
<span class="stat-badge stat-success">
<i class="fas fa-check-circle"></i> 成功: ${keyStats.success}
<i class="fas fa-check-circle"></i> ${i18n.t('stats.success')}: ${keyStats.success}
</span>
<span class="stat-badge stat-failure">
<i class="fas fa-times-circle"></i> 失败: ${keyStats.failure}
<i class="fas fa-times-circle"></i> ${i18n.t('stats.failure')}: ${keyStats.failure}
</span>
</div>
</div>
@@ -2231,14 +2240,14 @@ class CLIProxyManager {
const modalBody = document.getElementById('modal-body');
modalBody.innerHTML = `
<h3>添加Gemini API密钥</h3>
<h3>${i18n.t('ai_providers.gemini_add_modal_title')}</h3>
<div class="form-group">
<label for="new-gemini-key">API密钥:</label>
<input type="text" id="new-gemini-key" placeholder="请输入Gemini API密钥">
<label for="new-gemini-key">${i18n.t('ai_providers.gemini_add_modal_key_label')}</label>
<input type="text" id="new-gemini-key" placeholder="${i18n.t('ai_providers.gemini_add_modal_key_placeholder')}">
</div>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="manager.closeModal()">取消</button>
<button class="btn btn-primary" onclick="manager.addGeminiKey()">添加</button>
<button class="btn btn-secondary" onclick="manager.closeModal()">${i18n.t('common.cancel')}</button>
<button class="btn btn-primary" onclick="manager.addGeminiKey()">${i18n.t('common.add')}</button>
</div>
`;
@@ -2250,7 +2259,7 @@ class CLIProxyManager {
const newKey = document.getElementById('new-gemini-key').value.trim();
if (!newKey) {
this.showNotification('请输入Gemini API密钥', 'error');
this.showNotification(i18n.t('notification.please_enter') + ' ' + i18n.t('notification.gemini_api_key'), 'error');
return;
}
@@ -2267,9 +2276,9 @@ class CLIProxyManager {
this.clearCache(); // 清除缓存
this.closeModal();
this.loadGeminiKeys();
this.showNotification('Gemini密钥添加成功', 'success');
this.showNotification(i18n.t('notification.gemini_key_added'), 'success');
} catch (error) {
this.showNotification(`添加Gemini密钥失败: ${error.message}`, 'error');
this.showNotification(`${i18n.t('notification.add_failed')}: ${error.message}`, 'error');
}
}
@@ -2279,14 +2288,14 @@ class CLIProxyManager {
const modalBody = document.getElementById('modal-body');
modalBody.innerHTML = `
<h3>编辑Gemini API密钥</h3>
<h3>${i18n.t('ai_providers.gemini_edit_modal_title')}</h3>
<div class="form-group">
<label for="edit-gemini-key">API密钥:</label>
<label for="edit-gemini-key">${i18n.t('ai_providers.gemini_edit_modal_key_label')}</label>
<input type="text" id="edit-gemini-key" value="${currentKey}">
</div>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="manager.closeModal()">取消</button>
<button class="btn btn-primary" onclick="manager.updateGeminiKey('${currentKey}')">更新</button>
<button class="btn btn-secondary" onclick="manager.closeModal()">${i18n.t('common.cancel')}</button>
<button class="btn btn-primary" onclick="manager.updateGeminiKey('${currentKey}')">${i18n.t('common.update')}</button>
</div>
`;
@@ -2298,7 +2307,7 @@ class CLIProxyManager {
const newKey = document.getElementById('edit-gemini-key').value.trim();
if (!newKey) {
this.showNotification('请输入Gemini API密钥', 'error');
this.showNotification(i18n.t('notification.please_enter') + ' ' + i18n.t('notification.gemini_api_key'), 'error');
return;
}
@@ -2311,9 +2320,9 @@ class CLIProxyManager {
this.clearCache(); // 清除缓存
this.closeModal();
this.loadGeminiKeys();
this.showNotification('Gemini密钥更新成功', 'success');
this.showNotification(i18n.t('notification.gemini_key_updated'), 'success');
} catch (error) {
this.showNotification(`更新Gemini密钥失败: ${error.message}`, 'error');
this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error');
}
}
@@ -2325,9 +2334,9 @@ class CLIProxyManager {
await this.makeRequest(`/generative-language-api-key?value=${encodeURIComponent(key)}`, { method: 'DELETE' });
this.clearCache(); // 清除缓存
this.loadGeminiKeys();
this.showNotification('Gemini密钥删除成功', 'success');
this.showNotification(i18n.t('notification.gemini_key_deleted'), 'success');
} catch (error) {
this.showNotification(`删除Gemini密钥失败: ${error.message}`, 'error');
this.showNotification(`${i18n.t('notification.delete_failed')}: ${error.message}`, 'error');
}
}
@@ -2362,7 +2371,9 @@ class CLIProxyManager {
const stats = await this.getKeyStats();
container.innerHTML = keys.map((config, index) => {
const keyStats = stats[config['api-key']] || { success: 0, failure: 0 };
const rawKey = config['api-key'];
const masked = rawKey ? this.maskApiKey(rawKey) : '';
const keyStats = (rawKey && (stats[rawKey] || stats[masked])) || { success: 0, failure: 0 };
return `
<div class="provider-item">
<div class="item-content">
@@ -2372,10 +2383,10 @@ class CLIProxyManager {
${config['proxy-url'] ? `<div class="item-subtitle">${i18n.t('common.proxy_url')}: ${this.escapeHtml(config['proxy-url'])}</div>` : ''}
<div class="item-stats">
<span class="stat-badge stat-success">
<i class="fas fa-check-circle"></i> 成功: ${keyStats.success}
<i class="fas fa-check-circle"></i> ${i18n.t('stats.success')}: ${keyStats.success}
</span>
<span class="stat-badge stat-failure">
<i class="fas fa-times-circle"></i> 失败: ${keyStats.failure}
<i class="fas fa-times-circle"></i> ${i18n.t('stats.failure')}: ${keyStats.failure}
</span>
</div>
</div>
@@ -2452,9 +2463,9 @@ class CLIProxyManager {
this.clearCache(); // 清除缓存
this.closeModal();
this.loadCodexKeys();
this.showNotification('Codex配置添加成功', 'success');
this.showNotification(i18n.t('notification.codex_config_added'), 'success');
} catch (error) {
this.showNotification(`添加Codex配置失败: ${error.message}`, 'error');
this.showNotification(`${i18n.t('notification.add_failed')}: ${error.message}`, 'error');
}
}
@@ -2514,9 +2525,9 @@ class CLIProxyManager {
this.clearCache(); // 清除缓存
this.closeModal();
this.loadCodexKeys();
this.showNotification('Codex配置更新成功', 'success');
this.showNotification(i18n.t('notification.codex_config_updated'), 'success');
} catch (error) {
this.showNotification(`更新Codex配置失败: ${error.message}`, 'error');
this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error');
}
}
@@ -2528,9 +2539,9 @@ class CLIProxyManager {
await this.makeRequest(`/codex-api-key?api-key=${encodeURIComponent(apiKey)}`, { method: 'DELETE' });
this.clearCache(); // 清除缓存
this.loadCodexKeys();
this.showNotification('Codex配置删除成功', 'success');
this.showNotification(i18n.t('notification.codex_config_deleted'), 'success');
} catch (error) {
this.showNotification(`删除Codex配置失败: ${error.message}`, 'error');
this.showNotification(`${i18n.t('notification.delete_failed')}: ${error.message}`, 'error');
}
}
@@ -2565,7 +2576,9 @@ class CLIProxyManager {
const stats = await this.getKeyStats();
container.innerHTML = keys.map((config, index) => {
const keyStats = stats[config['api-key']] || { success: 0, failure: 0 };
const rawKey = config['api-key'];
const masked = rawKey ? this.maskApiKey(rawKey) : '';
const keyStats = (rawKey && (stats[rawKey] || stats[masked])) || { success: 0, failure: 0 };
return `
<div class="provider-item">
<div class="item-content">
@@ -2575,10 +2588,10 @@ class CLIProxyManager {
${config['proxy-url'] ? `<div class="item-subtitle">${i18n.t('common.proxy_url')}: ${this.escapeHtml(config['proxy-url'])}</div>` : ''}
<div class="item-stats">
<span class="stat-badge stat-success">
<i class="fas fa-check-circle"></i> 成功: ${keyStats.success}
<i class="fas fa-check-circle"></i> ${i18n.t('stats.success')}: ${keyStats.success}
</span>
<span class="stat-badge stat-failure">
<i class="fas fa-times-circle"></i> 失败: ${keyStats.failure}
<i class="fas fa-times-circle"></i> ${i18n.t('stats.failure')}: ${keyStats.failure}
</span>
</div>
</div>
@@ -2655,9 +2668,9 @@ class CLIProxyManager {
this.clearCache(); // 清除缓存
this.closeModal();
this.loadClaudeKeys();
this.showNotification('Claude配置添加成功', 'success');
this.showNotification(i18n.t('notification.claude_config_added'), 'success');
} catch (error) {
this.showNotification(`添加Claude配置失败: ${error.message}`, 'error');
this.showNotification(`${i18n.t('notification.add_failed')}: ${error.message}`, 'error');
}
}
@@ -2717,9 +2730,9 @@ class CLIProxyManager {
this.clearCache(); // 清除缓存
this.closeModal();
this.loadClaudeKeys();
this.showNotification('Claude配置更新成功', 'success');
this.showNotification(i18n.t('notification.claude_config_updated'), 'success');
} catch (error) {
this.showNotification(`更新Claude配置失败: ${error.message}`, 'error');
this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error');
}
}
@@ -2731,9 +2744,9 @@ class CLIProxyManager {
await this.makeRequest(`/claude-api-key?api-key=${encodeURIComponent(apiKey)}`, { method: 'DELETE' });
this.clearCache(); // 清除缓存
this.loadClaudeKeys();
this.showNotification('Claude配置删除成功', 'success');
this.showNotification(i18n.t('notification.claude_config_deleted'), 'success');
} catch (error) {
this.showNotification(`删除Claude配置失败: ${error.message}`, 'error');
this.showNotification(`${i18n.t('notification.delete_failed')}: ${error.message}`, 'error');
}
}
@@ -2753,7 +2766,7 @@ class CLIProxyManager {
async renderOpenAIProviders(providers) {
const container = document.getElementById('openai-providers-list');
if (providers.length === 0) {
if (!Array.isArray(providers) || providers.length === 0) {
container.innerHTML = `
<div class="empty-state">
<i class="fas fa-plug"></i>
@@ -2761,19 +2774,49 @@ class CLIProxyManager {
<p>${i18n.t('ai_providers.openai_empty_desc')}</p>
</div>
`;
// 重置样式
container.style.maxHeight = '';
container.style.overflowY = '';
return;
}
// 根据提供商数量设置滚动条
if (providers.length > 5) {
container.style.maxHeight = '400px';
container.style.overflowY = 'auto';
} else {
container.style.maxHeight = '';
container.style.overflowY = '';
}
// 获取使用统计,按 source 聚合
const stats = await this.getKeyStats();
container.innerHTML = providers.map((provider, index) => {
const apiKeyEntries = provider['api-key-entries'] || [];
const item = typeof provider === 'object' && provider !== null ? provider : {};
// 处理两种API密钥格式新的 api-key-entries 和旧的 api-keys
let apiKeyEntries = [];
if (Array.isArray(item['api-key-entries'])) {
// 新格式:{api-key: "...", proxy-url: "..."}
apiKeyEntries = item['api-key-entries'];
} else if (Array.isArray(item['api-keys'])) {
// 旧格式:简单的字符串数组
apiKeyEntries = item['api-keys'].map(key => ({ 'api-key': key }));
}
const models = Array.isArray(item.models) ? item.models : [];
const name = item.name || '';
const baseUrl = item['base-url'] || '';
let totalSuccess = 0;
let totalFailure = 0;
apiKeyEntries.forEach(entry => {
const keyStats = stats[entry['api-key']] || { success: 0, failure: 0 };
const key = entry && entry['api-key'] ? entry['api-key'] : '';
if (!key) return;
const masked = this.maskApiKey(key);
const keyStats = stats[key] || stats[masked] || { success: 0, failure: 0 };
totalSuccess += keyStats.success;
totalFailure += keyStats.failure;
});
@@ -2781,30 +2824,30 @@ class CLIProxyManager {
return `
<div class="provider-item">
<div class="item-content">
<div class="item-title">${this.escapeHtml(provider.name)}</div>
<div class="item-subtitle">${i18n.t('common.base_url')}: ${this.escapeHtml(provider['base-url'])}</div>
<div class="item-subtitle">${i18n.t('ai_providers.openai_keys_count')}: ${(provider['api-key-entries'] || []).length}</div>
<div class="item-subtitle">${i18n.t('ai_providers.openai_models_count')}: ${(provider.models || []).length}</div>
${this.renderOpenAIModelBadges(provider.models || [])}
<div class="item-title">${this.escapeHtml(name)}</div>
<div class="item-subtitle">${i18n.t('common.base_url')}: ${this.escapeHtml(baseUrl)}</div>
<div class="item-subtitle">${i18n.t('ai_providers.openai_keys_count')}: ${apiKeyEntries.length}</div>
<div class="item-subtitle">${i18n.t('ai_providers.openai_models_count')}: ${models.length}</div>
${this.renderOpenAIModelBadges(models)}
<div class="item-stats">
<span class="stat-badge stat-success">
<i class="fas fa-check-circle"></i> 成功: ${totalSuccess}
<i class="fas fa-check-circle"></i> ${i18n.t('stats.success')}: ${totalSuccess}
</span>
<span class="stat-badge stat-failure">
<i class="fas fa-times-circle"></i> 失败: ${totalFailure}
<i class="fas fa-times-circle"></i> ${i18n.t('stats.failure')}: ${totalFailure}
</span>
</div>
</div>
<div class="item-actions">
<button class="btn btn-secondary" onclick="manager.editOpenAIProvider(${index}, ${JSON.stringify(provider).replace(/"/g, '&quot;')})">
<button class="btn btn-secondary" onclick="manager.editOpenAIProvider(${index}, ${JSON.stringify(item).replace(/"/g, '&quot;')})">
<i class="fas fa-edit"></i>
</button>
<button class="btn btn-danger" onclick="manager.deleteOpenAIProvider('${provider.name}')">
<button class="btn btn-danger" onclick="manager.deleteOpenAIProvider('${this.escapeHtml(name)}')">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
`}).join('');
`;}).join('');
}
// 显示添加OpenAI提供商模态框
@@ -2886,9 +2929,9 @@ class CLIProxyManager {
this.clearCache(); // 清除缓存
this.closeModal();
this.loadOpenAIProviders();
this.showNotification('OpenAI提供商添加成功', 'success');
this.showNotification(i18n.t('notification.openai_provider_added'), 'success');
} catch (error) {
this.showNotification(`添加OpenAI提供商失败: ${error.message}`, 'error');
this.showNotification(`${i18n.t('notification.add_failed')}: ${error.message}`, 'error');
}
}
@@ -2897,27 +2940,37 @@ class CLIProxyManager {
const modal = document.getElementById('modal');
const modalBody = document.getElementById('modal-body');
const apiKeyEntries = provider['api-key-entries'] || [];
const apiKeysText = apiKeyEntries.map(entry => entry['api-key'] || '').join('\n');
const proxiesText = apiKeyEntries.map(entry => entry['proxy-url'] || '').join('\n');
// 处理两种API密钥格式新的 api-key-entries 和旧的 api-keys
let apiKeyEntries = [];
if (Array.isArray(provider?.['api-key-entries'])) {
// 新格式:{api-key: "...", proxy-url: "..."}
apiKeyEntries = provider['api-key-entries'];
} else if (Array.isArray(provider?.['api-keys'])) {
// 旧格式:简单的字符串数组
apiKeyEntries = provider['api-keys'].map(key => ({ 'api-key': key, 'proxy-url': '' }));
}
const apiKeysText = apiKeyEntries.map(entry => entry?.['api-key'] || '').join('\n');
const proxiesText = apiKeyEntries.map(entry => entry?.['proxy-url'] || '').join('\n');
const models = Array.isArray(provider?.models) ? provider.models : [];
modalBody.innerHTML = `
<h3>${i18n.t('ai_providers.openai_edit_modal_title')}</h3>
<div class="form-group">
<label for="edit-provider-name">${i18n.t('ai_providers.openai_edit_modal_name_label')}</label>
<input type="text" id="edit-provider-name" value="${provider.name}">
<input type="text" id="edit-provider-name" value="${provider?.name ? this.escapeHtml(provider.name) : ''}">
</div>
<div class="form-group">
<label for="edit-provider-url">${i18n.t('ai_providers.openai_edit_modal_url_label')}</label>
<input type="text" id="edit-provider-url" value="${provider['base-url']}">
<input type="text" id="edit-provider-url" value="${provider?.['base-url'] ? this.escapeHtml(provider['base-url']) : ''}">
</div>
<div class="form-group">
<label for="edit-provider-keys">${i18n.t('ai_providers.openai_edit_modal_keys_label')}</label>
<textarea id="edit-provider-keys" rows="3">${apiKeysText}</textarea>
<textarea id="edit-provider-keys" rows="3">${this.escapeHtml(apiKeysText)}</textarea>
</div>
<div class="form-group">
<label for="edit-provider-proxies">${i18n.t('ai_providers.openai_edit_modal_keys_proxy_label')}</label>
<textarea id="edit-provider-proxies" rows="3">${proxiesText}</textarea>
<textarea id="edit-provider-proxies" rows="3">${this.escapeHtml(proxiesText)}</textarea>
</div>
<div class="form-group">
<label>${i18n.t('ai_providers.openai_edit_modal_models_label')}</label>
@@ -2932,7 +2985,7 @@ class CLIProxyManager {
`;
modal.style.display = 'block';
this.populateModelFields('edit-provider-models-wrapper', provider.models || []);
this.populateModelFields('edit-provider-models-wrapper', models);
}
// 更新OpenAI提供商
@@ -2970,9 +3023,9 @@ class CLIProxyManager {
this.clearCache(); // 清除缓存
this.closeModal();
this.loadOpenAIProviders();
this.showNotification('OpenAI提供商更新成功', 'success');
this.showNotification(i18n.t('notification.openai_provider_updated'), 'success');
} catch (error) {
this.showNotification(`更新OpenAI提供商失败: ${error.message}`, 'error');
this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error');
}
}
@@ -2984,9 +3037,9 @@ class CLIProxyManager {
await this.makeRequest(`/openai-compatibility?name=${encodeURIComponent(name)}`, { method: 'DELETE' });
this.clearCache(); // 清除缓存
this.loadOpenAIProviders();
this.showNotification('OpenAI提供商删除成功', 'success');
this.showNotification(i18n.t('notification.openai_provider_deleted'), 'success');
} catch (error) {
this.showNotification(`删除OpenAI提供商失败: ${error.message}`, 'error');
this.showNotification(`${i18n.t('notification.delete_failed')}: ${error.message}`, 'error');
}
}
@@ -2994,14 +3047,14 @@ class CLIProxyManager {
async loadAuthFiles() {
try {
const data = await this.makeRequest('/auth-files');
this.renderAuthFiles(data.files || []);
await this.renderAuthFiles(data.files || []);
} catch (error) {
console.error('加载认证文件失败:', error);
}
}
// 渲染认证文件列表
renderAuthFiles(files) {
async renderAuthFiles(files) {
const container = document.getElementById('auth-files-list');
if (files.length === 0) {
@@ -3015,12 +3068,70 @@ class CLIProxyManager {
return;
}
container.innerHTML = files.map(file => `
// 获取使用统计,按 source 聚合
const stats = await this.getKeyStats();
container.innerHTML = files.map(file => {
// 认证文件的统计匹配逻辑:
// 1. 首先尝试完整文件名匹配
// 2. 如果没有匹配,尝试脱敏文件名匹配(去掉扩展名后的脱敏版本)
let fileStats = stats[file.name] || { success: 0, failure: 0 };
// 如果完整文件名没有统计,尝试基于文件名的脱敏版本匹配
if (fileStats.success === 0 && fileStats.failure === 0) {
const nameWithoutExt = file.name.replace(/\.[^/.]+$/, ""); // 去掉扩展名
// 后端有两种脱敏规则,都要尝试:
// 规则1完整描述脱敏 - mikiunameina@gmail.com (ethereal-advice-465201-t0) -> 脱敏 -> miki...-t0)
// 规则2直接整体脱敏 - mikiunameina@gmail.com-ethereal-advice-465201-t0 -> 脱敏 -> ???
const possibleSources = [];
// 规则1尝试完整描述脱敏
const match = nameWithoutExt.match(/^([^@]+@[^-]+)-(.+)$/);
if (match) {
const email = match[1]; // mikiunameina@gmail.com
const projectName = match[2]; // ethereal-advice-465201-t0
// 组合成完整的描述格式
const fullDescription = `${email} (${projectName})`;
// 对完整描述进行脱敏
const maskedDescription = this.maskApiKey(fullDescription);
possibleSources.push(maskedDescription);
}
// 规则2类型-个人标识.json 格式,去掉类型前缀后脱敏
const typeMatch = nameWithoutExt.match(/^[^-]+-(.+)$/);
if (typeMatch) {
const personalId = typeMatch[1]; // 个人标识部分
const maskedPersonalId = this.maskApiKey(personalId);
possibleSources.push(maskedPersonalId);
}
// 查找第一个有统计数据的匹配
for (const source of possibleSources) {
if (stats[source] && (stats[source].success > 0 || stats[source].failure > 0)) {
fileStats = stats[source];
break;
}
}
}
return `
<div class="file-item">
<div class="item-content">
<div class="item-title">${file.name}</div>
<div class="item-subtitle">${i18n.t('auth_files.file_size')}: ${this.formatFileSize(file.size)}</div>
<div class="item-subtitle">${i18n.t('auth_files.file_modified')}: ${new Date(file.modtime).toLocaleString(i18n.currentLanguage === 'zh-CN' ? 'zh-CN' : 'en-US')}</div>
<div class="item-stats">
<span class="stat-badge stat-success">
<i class="fas fa-check-circle"></i> ${i18n.t('stats.success')}: ${fileStats.success}
</span>
<span class="stat-badge stat-failure">
<i class="fas fa-times-circle"></i> ${i18n.t('stats.failure')}: ${fileStats.failure}
</span>
</div>
</div>
<div class="item-actions">
<button class="btn btn-primary" onclick="manager.downloadAuthFile('${file.name}')">
@@ -3031,7 +3142,8 @@ class CLIProxyManager {
</button>
</div>
</div>
`).join('');
`;
}).join('');
}
// 格式化文件大小
@@ -3079,7 +3191,7 @@ class CLIProxyManager {
this.loadAuthFiles();
this.showNotification(i18n.t('auth_files.upload_success'), 'success');
} catch (error) {
this.showNotification(`文件上传失败: ${error.message}`, 'error');
this.showNotification(`${i18n.t('notification.upload_failed')}: ${error.message}`, 'error');
}
// 清空文件输入
@@ -3109,7 +3221,7 @@ class CLIProxyManager {
this.showNotification(i18n.t('auth_files.download_success'), 'success');
} catch (error) {
this.showNotification(`文件下载失败: ${error.message}`, 'error');
this.showNotification(`${i18n.t('notification.download_failed')}: ${error.message}`, 'error');
}
}
@@ -3123,7 +3235,7 @@ class CLIProxyManager {
this.loadAuthFiles();
this.showNotification(i18n.t('auth_files.delete_success'), 'success');
} catch (error) {
this.showNotification(`文件删除失败: ${error.message}`, 'error');
this.showNotification(`${i18n.t('notification.delete_failed')}: ${error.message}`, 'error');
}
}
@@ -3137,7 +3249,7 @@ class CLIProxyManager {
this.loadAuthFiles();
this.showNotification(`${i18n.t('auth_files.delete_all_success')} ${response.deleted} ${i18n.t('auth_files.files_count')}`, 'success');
} catch (error) {
this.showNotification(`删除文件失败: ${error.message}`, 'error');
this.showNotification(`${i18n.t('notification.delete_failed')}: ${error.message}`, 'error');
}
}
@@ -3203,12 +3315,12 @@ class CLIProxyManager {
if (urlInput && urlInput.value) {
try {
await navigator.clipboard.writeText(urlInput.value);
this.showNotification('链接已复制到剪贴板', 'success');
this.showNotification(i18n.t('notification.link_copied'), 'success');
} catch (error) {
// 降级方案:使用传统的复制方法
urlInput.select();
document.execCommand('copy');
this.showNotification('链接已复制到剪贴板', 'success');
this.showNotification(i18n.t('notification.link_copied'), 'success');
}
}
}
@@ -3216,7 +3328,7 @@ class CLIProxyManager {
// 开始轮询 OAuth 状态
startCodexOAuthPolling(state) {
if (!state) {
this.showNotification('无法获取认证状态参数', 'error');
this.showNotification(i18n.t('auth_login.missing_state'), 'error');
return;
}
@@ -3348,12 +3460,12 @@ class CLIProxyManager {
if (urlInput && urlInput.value) {
try {
await navigator.clipboard.writeText(urlInput.value);
this.showNotification('链接已复制到剪贴板', 'success');
this.showNotification(i18n.t('notification.link_copied'), 'success');
} catch (error) {
// 降级方案:使用传统的复制方法
urlInput.select();
document.execCommand('copy');
this.showNotification('链接已复制到剪贴板', 'success');
this.showNotification(i18n.t('notification.link_copied'), 'success');
}
}
}
@@ -3361,7 +3473,7 @@ class CLIProxyManager {
// 开始轮询 Anthropic OAuth 状态
startAnthropicOAuthPolling(state) {
if (!state) {
this.showNotification('无法获取认证状态参数', 'error');
this.showNotification(i18n.t('auth_login.missing_state'), 'error');
return;
}
@@ -3502,12 +3614,12 @@ class CLIProxyManager {
if (urlInput && urlInput.value) {
try {
await navigator.clipboard.writeText(urlInput.value);
this.showNotification('链接已复制到剪贴板', 'success');
this.showNotification(i18n.t('notification.link_copied'), 'success');
} catch (error) {
// 降级方案:使用传统的复制方法
urlInput.select();
document.execCommand('copy');
this.showNotification('链接已复制到剪贴板', 'success');
this.showNotification(i18n.t('notification.link_copied'), 'success');
}
}
}
@@ -3515,7 +3627,7 @@ class CLIProxyManager {
// 开始轮询 Gemini CLI OAuth 状态
startGeminiCliOAuthPolling(state) {
if (!state) {
this.showNotification('无法获取认证状态参数', 'error');
this.showNotification(i18n.t('auth_login.missing_state'), 'error');
return;
}
@@ -3647,12 +3759,12 @@ class CLIProxyManager {
if (urlInput && urlInput.value) {
try {
await navigator.clipboard.writeText(urlInput.value);
this.showNotification('链接已复制到剪贴板', 'success');
this.showNotification(i18n.t('notification.link_copied'), 'success');
} catch (error) {
// 降级方案:使用传统的复制方法
urlInput.select();
document.execCommand('copy');
this.showNotification('链接已复制到剪贴板', 'success');
this.showNotification(i18n.t('notification.link_copied'), 'success');
}
}
}
@@ -3660,7 +3772,7 @@ class CLIProxyManager {
// 开始轮询 Qwen OAuth 状态
startQwenOAuthPolling(state) {
if (!state) {
this.showNotification('无法获取认证状态参数', 'error');
this.showNotification(i18n.t('auth_login.missing_state'), 'error');
return;
}
@@ -3792,12 +3904,12 @@ class CLIProxyManager {
if (urlInput && urlInput.value) {
try {
await navigator.clipboard.writeText(urlInput.value);
this.showNotification('链接已复制到剪贴板', 'success');
this.showNotification(i18n.t('notification.link_copied'), 'success');
} catch (error) {
// 降级方案:使用传统的复制方法
urlInput.select();
document.execCommand('copy');
this.showNotification('链接已复制到剪贴板', 'success');
this.showNotification(i18n.t('notification.link_copied'), 'success');
}
}
}
@@ -3805,7 +3917,7 @@ class CLIProxyManager {
// 开始轮询 iFlow OAuth 状态
startIflowOAuthPolling(state) {
if (!state) {
this.showNotification('无法获取认证状态参数', 'error');
this.showNotification(i18n.t('auth_login.missing_state'), 'error');
return;
}
@@ -3927,8 +4039,8 @@ class CLIProxyManager {
};
}
const success = detail.success;
if (success === false) {
const isFailed = detail.failed === true;
if (isFailed) {
sourceStats[source].failure += 1;
} else {
sourceStats[source].success += 1;

46
i18n.js
View File

@@ -38,6 +38,8 @@ const i18n = {
'common.base_url': '地址',
'common.proxy_url': '代理',
'common.alias': '别名',
'common.failure': '失败',
'common.unknown_error': '未知错误',
// 页面标题
'title.main': 'CLI Proxy API Management Center',
@@ -275,6 +277,7 @@ const i18n = {
'auth_login.qwen_oauth_status_error': '认证失败:',
'auth_login.qwen_oauth_start_error': '启动 Qwen OAuth 失败:',
'auth_login.qwen_oauth_polling_error': '检查认证状态失败:',
'auth_login.missing_state': '无法获取认证状态参数',
// iFlow OAuth
'auth_login.iflow_oauth_title': 'iFlow OAuth',
@@ -308,6 +311,8 @@ const i18n = {
'usage_stats.tokens_count': 'Token数量',
'usage_stats.models': '模型统计',
'usage_stats.success_rate': '成功率',
'stats.success': '成功',
'stats.failure': '失败',
// 日志查看
'logs.title': '日志查看',
@@ -347,6 +352,7 @@ const i18n = {
'config_management.status_save_failed': '保存失败',
'config_management.save_success': '配置已保存',
'config_management.error_yaml_not_supported': '服务器未返回 YAML 格式,请确认 /config.yaml 接口可用',
'config_management.editor_placeholder': 'key: value',
// 系统信息
'system_info.title': '系统信息',
@@ -402,6 +408,7 @@ const i18n = {
'notification.gemini_api_key': 'Gemini API密钥',
'notification.codex_api_key': 'Codex API密钥',
'notification.claude_api_key': 'Claude API密钥',
'notification.link_copied': '链接已复制到剪贴板',
// 语言切换
'language.switch': '语言',
@@ -416,6 +423,10 @@ const i18n = {
'theme.switch_to_dark': '切换到暗色模式',
'theme.auto': '跟随系统',
// 侧边栏
'sidebar.toggle_expand': '展开侧边栏',
'sidebar.toggle_collapse': '收起侧边栏',
// 页脚
'footer.version': '版本',
'footer.author': '作者'
@@ -453,6 +464,8 @@ const i18n = {
'common.base_url': 'Address',
'common.proxy_url': 'Proxy',
'common.alias': 'Alias',
'common.failure': 'Failure',
'common.unknown_error': 'Unknown error',
// Page titles
'title.main': 'CLI Proxy API Management Center',
@@ -689,6 +702,7 @@ const i18n = {
'auth_login.qwen_oauth_status_error': 'Authentication failed:',
'auth_login.qwen_oauth_start_error': 'Failed to start Qwen OAuth:',
'auth_login.qwen_oauth_polling_error': 'Failed to check authentication status:',
'auth_login.missing_state': 'Unable to retrieve authentication state parameter',
// iFlow OAuth
'auth_login.iflow_oauth_title': 'iFlow OAuth',
@@ -722,6 +736,8 @@ const i18n = {
'usage_stats.tokens_count': 'Token Count',
'usage_stats.models': 'Model Statistics',
'usage_stats.success_rate': 'Success Rate',
'stats.success': 'Success',
'stats.failure': 'Failure',
// Logs viewer
'logs.title': 'Logs Viewer',
@@ -761,6 +777,7 @@ const i18n = {
'config_management.status_save_failed': 'Save failed',
'config_management.save_success': 'Configuration saved successfully',
'config_management.error_yaml_not_supported': 'Server did not return YAML. Verify the /config.yaml endpoint is available.',
'config_management.editor_placeholder': 'key: value',
// System info
'system_info.title': 'System Information',
@@ -816,6 +833,7 @@ const i18n = {
'notification.gemini_api_key': 'Gemini API key',
'notification.codex_api_key': 'Codex API key',
'notification.claude_api_key': 'Claude API key',
'notification.link_copied': 'Link copied to clipboard',
// Language switch
'language.switch': 'Language',
@@ -830,6 +848,10 @@ const i18n = {
'theme.switch_to_dark': 'Switch to dark mode',
'theme.auto': 'Follow system',
// Sidebar
'sidebar.toggle_expand': 'Expand sidebar',
'sidebar.toggle_collapse': 'Collapse sidebar',
// Footer
'footer.version': 'Version',
'footer.author': 'Author'
@@ -879,6 +901,30 @@ const i18n = {
}
});
// 更新所有包含 data-i18n-placeholder 的输入框占位符
document.querySelectorAll('[data-i18n-placeholder]').forEach(element => {
const key = element.getAttribute('data-i18n-placeholder');
element.placeholder = this.t(key);
});
// 更新 data-i18n-title
document.querySelectorAll('[data-i18n-title]').forEach(element => {
const key = element.getAttribute('data-i18n-title');
element.title = this.t(key);
});
// 更新 data-i18n-tooltip
document.querySelectorAll('[data-i18n-tooltip]').forEach(element => {
const key = element.getAttribute('data-i18n-tooltip');
element.setAttribute('data-tooltip', this.t(key));
});
// 更新 data-i18n-text常用于按钮或标签
document.querySelectorAll('[data-i18n-text]').forEach(element => {
const key = element.getAttribute('data-i18n-text');
element.textContent = this.t(key);
});
// 更新所有带有 data-i18n-html 属性的元素支持HTML
document.querySelectorAll('[data-i18n-html]').forEach(element => {
const key = element.getAttribute('data-i18n-html');

View File

@@ -77,8 +77,7 @@
<div class="form-group">
<label for="login-api-base" data-i18n="login.custom_connection_label">自定义连接地址:</label>
<div class="input-group">
<input type="text" id="login-api-base" data-i18n="login.custom_connection_placeholder"
placeholder="例如: https://example.com:8317">
<input type="text" id="login-api-base" data-i18n-placeholder="login.custom_connection_placeholder">
<button type="button" id="login-reset-api-base"
class="btn btn-secondary connection-reset-btn">
<i class="fas fa-location-arrow"></i>
@@ -91,8 +90,7 @@
<div class="form-group">
<label for="login-management-key" data-i18n="login.management_key_label">管理密钥:</label>
<div class="input-group">
<input type="password" id="login-management-key"
data-i18n="login.management_key_placeholder" placeholder="请输入管理密钥" required>
<input type="password" id="login-management-key" data-i18n-placeholder="login.management_key_placeholder" required>
<button type="button" class="btn btn-secondary toggle-key-visibility">
<i class="fas fa-eye"></i>
</button>
@@ -123,7 +121,7 @@
<button class="mobile-menu-btn" id="mobile-menu-btn">
<i class="fas fa-bars"></i>
</button>
<button class="sidebar-toggle-btn-desktop" id="sidebar-toggle-btn-desktop" title="收起/展开侧边栏">
<button class="sidebar-toggle-btn-desktop" id="sidebar-toggle-btn-desktop" data-i18n-title="sidebar.toggle_collapse">
<i class="fas fa-bars"></i>
</button>
<div class="top-navbar-brand">
@@ -161,29 +159,29 @@
<nav class="sidebar" id="sidebar">
<!-- 导航菜单 -->
<ul class="nav-menu">
<li data-tooltip="基础设置"><a href="#basic-settings" class="nav-item active"
<li data-i18n-tooltip="nav.basic_settings"><a href="#basic-settings" class="nav-item active"
data-section="basic-settings">
<i class="fas fa-sliders-h"></i> <span data-i18n="nav.basic_settings">基础设置</span>
</a></li>
<li data-tooltip="API 密钥"><a href="#api-keys" class="nav-item" data-section="api-keys">
<li data-i18n-tooltip="nav.api_keys"><a href="#api-keys" class="nav-item" data-section="api-keys">
<i class="fas fa-key"></i> <span data-i18n="nav.api_keys">API 密钥</span>
</a></li>
<li data-tooltip="AI 提供商"><a href="#ai-providers" class="nav-item" data-section="ai-providers">
<li data-i18n-tooltip="nav.ai_providers"><a href="#ai-providers" class="nav-item" data-section="ai-providers">
<i class="fas fa-robot"></i> <span data-i18n="nav.ai_providers">AI 提供商</span>
</a></li>
<li data-tooltip="认证文件"><a href="#auth-files" class="nav-item" data-section="auth-files">
<li data-i18n-tooltip="nav.auth_files"><a href="#auth-files" class="nav-item" data-section="auth-files">
<i class="fas fa-file-alt"></i> <span data-i18n="nav.auth_files">认证文件</span>
</a></li>
<li data-tooltip="使用统计"><a href="#usage-stats" class="nav-item" data-section="usage-stats">
<li data-i18n-tooltip="nav.usage_stats"><a href="#usage-stats" class="nav-item" data-section="usage-stats">
<i class="fas fa-chart-line"></i> <span data-i18n="nav.usage_stats">使用统计</span>
</a></li>
<li data-tooltip="配置管理"><a href="#config-management" class="nav-item" data-section="config-management">
<li data-i18n-tooltip="nav.config_management"><a href="#config-management" class="nav-item" data-section="config-management">
<i class="fas fa-cog"></i> <span data-i18n="nav.config_management">配置管理</span>
</a></li>
<li id="logs-nav-item" data-tooltip="日志查看" style="display: none;"><a href="#logs" class="nav-item" data-section="logs">
<li id="logs-nav-item" data-i18n-tooltip="nav.logs" style="display: none;"><a href="#logs" class="nav-item" data-section="logs">
<i class="fas fa-scroll"></i> <span data-i18n="nav.logs">日志查看</span>
</a></li>
<li data-tooltip="系统信息"><a href="#system-info" class="nav-item" data-section="system-info">
<li data-i18n-tooltip="nav.system_info"><a href="#system-info" class="nav-item" data-section="system-info">
<i class="fas fa-info-circle"></i> <span data-i18n="nav.system_info">系统信息</span>
</a></li>
</ul>
@@ -230,9 +228,7 @@
<label for="proxy-url" data-i18n="basic_settings.proxy_url_label">代理
URL:</label>
<div class="input-group">
<input type="text" id="proxy-url"
data-i18n="basic_settings.proxy_url_placeholder"
placeholder="例如: socks5://user:pass@127.0.0.1:1080/">
<input type="text" id="proxy-url" data-i18n-placeholder="basic_settings.proxy_url_placeholder">
<button id="update-proxy" class="btn btn-primary"
data-i18n="basic_settings.proxy_update">更新</button>
<button id="clear-proxy" class="btn btn-danger"
@@ -461,7 +457,7 @@
<div class="form-group">
<label data-i18n="auth_login.codex_oauth_url_label">授权链接:</label>
<div class="input-group">
<input type="text" id="codex-oauth-url" readonly>
<input type="text" id="codex-oauth-url" readonly>
<button id="codex-open-link" class="btn btn-primary">
<i class="fas fa-external-link-alt"></i> <span
data-i18n="auth_login.codex_open_link">打开链接</span>
@@ -533,11 +529,10 @@
data-i18n="auth_login.gemini_cli_project_id_label">Google Cloud 项目 ID
(可选):</label>
<input type="text" id="gemini-cli-project-id"
data-i18n="auth_login.gemini_cli_project_id_placeholder"
placeholder="输入 Google Cloud 项目 ID (可选)">
<div class="form-hint" data-i18n="auth_login.gemini_cli_project_id_hint">
如果指定了项目 ID将使用该项目的认证信息。
</div>
data-i18n-placeholder="auth_login.gemini_cli_project_id_placeholder">
<div class="form-hint" data-i18n="auth_login.gemini_cli_project_id_hint">
如果指定了项目 ID将使用该项目的认证信息。
</div>
</div>
<div id="gemini-cli-oauth-content" style="display: none;">
<div class="form-group">
@@ -794,7 +789,7 @@
<div class="card-content">
<p class="form-hint" data-i18n="config_management.description">查看并编辑服务器上的 config.yaml 配置文件。保存前请确认语法正确。</p>
<div class="yaml-editor-container">
<textarea id="config-editor" class="yaml-editor" spellcheck="false" placeholder="key: value"></textarea>
<textarea id="config-editor" class="yaml-editor" spellcheck="false" data-i18n="config_management.editor_placeholder"></textarea>
<div id="config-editor-status" class="editor-status" data-i18n="config_management.status_idle">等待操作</div>
</div>
</div>

View File

@@ -1445,13 +1445,21 @@ input:checked+.slider:before {
}
/* 列表样式 */
.key-list,
.provider-list,
.file-list {
.key-list {
max-height: 400px;
overflow-y: auto;
}
.file-list {
/* 认证文件列表填满页面,保留版本信息空间 */
max-height: calc(100vh - 300px); /* 减去导航栏、padding和版本信息的高度 */
overflow-y: auto;
}
.provider-list {
/* 默认不限制高度,动态设置 */
}
.key-item,
.provider-item,
.file-item {