mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-03 11:20:50 +08:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e92784f951 | ||
|
|
d26695da76 | ||
|
|
8964030ade |
170
app.js
170
app.js
@@ -2046,8 +2046,14 @@ class CLIProxyManager {
|
|||||||
|
|
||||||
// 遮蔽API密钥显示
|
// 遮蔽API密钥显示
|
||||||
maskApiKey(key) {
|
maskApiKey(key) {
|
||||||
if (key.length <= 8) return key;
|
if (key.length > 8) {
|
||||||
return key.substring(0, 4) + '...' + key.substring(key.length - 4);
|
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
|
// HTML 转义,防止 XSS
|
||||||
@@ -2198,7 +2204,8 @@ class CLIProxyManager {
|
|||||||
const stats = await this.getKeyStats();
|
const stats = await this.getKeyStats();
|
||||||
|
|
||||||
container.innerHTML = keys.map((key, index) => {
|
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 `
|
return `
|
||||||
<div class="key-item">
|
<div class="key-item">
|
||||||
<div class="item-content">
|
<div class="item-content">
|
||||||
@@ -2362,7 +2369,9 @@ class CLIProxyManager {
|
|||||||
const stats = await this.getKeyStats();
|
const stats = await this.getKeyStats();
|
||||||
|
|
||||||
container.innerHTML = keys.map((config, index) => {
|
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 `
|
return `
|
||||||
<div class="provider-item">
|
<div class="provider-item">
|
||||||
<div class="item-content">
|
<div class="item-content">
|
||||||
@@ -2565,7 +2574,9 @@ class CLIProxyManager {
|
|||||||
const stats = await this.getKeyStats();
|
const stats = await this.getKeyStats();
|
||||||
|
|
||||||
container.innerHTML = keys.map((config, index) => {
|
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 `
|
return `
|
||||||
<div class="provider-item">
|
<div class="provider-item">
|
||||||
<div class="item-content">
|
<div class="item-content">
|
||||||
@@ -2753,7 +2764,7 @@ class CLIProxyManager {
|
|||||||
async renderOpenAIProviders(providers) {
|
async renderOpenAIProviders(providers) {
|
||||||
const container = document.getElementById('openai-providers-list');
|
const container = document.getElementById('openai-providers-list');
|
||||||
|
|
||||||
if (providers.length === 0) {
|
if (!Array.isArray(providers) || providers.length === 0) {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<i class="fas fa-plug"></i>
|
<i class="fas fa-plug"></i>
|
||||||
@@ -2761,19 +2772,49 @@ class CLIProxyManager {
|
|||||||
<p>${i18n.t('ai_providers.openai_empty_desc')}</p>
|
<p>${i18n.t('ai_providers.openai_empty_desc')}</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
// 重置样式
|
||||||
|
container.style.maxHeight = '';
|
||||||
|
container.style.overflowY = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 根据提供商数量设置滚动条
|
||||||
|
if (providers.length > 5) {
|
||||||
|
container.style.maxHeight = '400px';
|
||||||
|
container.style.overflowY = 'auto';
|
||||||
|
} else {
|
||||||
|
container.style.maxHeight = '';
|
||||||
|
container.style.overflowY = '';
|
||||||
|
}
|
||||||
|
|
||||||
// 获取使用统计,按 source 聚合
|
// 获取使用统计,按 source 聚合
|
||||||
const stats = await this.getKeyStats();
|
const stats = await this.getKeyStats();
|
||||||
|
|
||||||
container.innerHTML = providers.map((provider, index) => {
|
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 totalSuccess = 0;
|
||||||
let totalFailure = 0;
|
let totalFailure = 0;
|
||||||
|
|
||||||
apiKeyEntries.forEach(entry => {
|
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;
|
totalSuccess += keyStats.success;
|
||||||
totalFailure += keyStats.failure;
|
totalFailure += keyStats.failure;
|
||||||
});
|
});
|
||||||
@@ -2781,11 +2822,11 @@ class CLIProxyManager {
|
|||||||
return `
|
return `
|
||||||
<div class="provider-item">
|
<div class="provider-item">
|
||||||
<div class="item-content">
|
<div class="item-content">
|
||||||
<div class="item-title">${this.escapeHtml(provider.name)}</div>
|
<div class="item-title">${this.escapeHtml(name)}</div>
|
||||||
<div class="item-subtitle">${i18n.t('common.base_url')}: ${this.escapeHtml(provider['base-url'])}</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')}: ${(provider['api-key-entries'] || []).length}</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')}: ${(provider.models || []).length}</div>
|
<div class="item-subtitle">${i18n.t('ai_providers.openai_models_count')}: ${models.length}</div>
|
||||||
${this.renderOpenAIModelBadges(provider.models || [])}
|
${this.renderOpenAIModelBadges(models)}
|
||||||
<div class="item-stats">
|
<div class="item-stats">
|
||||||
<span class="stat-badge stat-success">
|
<span class="stat-badge stat-success">
|
||||||
<i class="fas fa-check-circle"></i> 成功: ${totalSuccess}
|
<i class="fas fa-check-circle"></i> 成功: ${totalSuccess}
|
||||||
@@ -2796,15 +2837,15 @@ class CLIProxyManager {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="item-actions">
|
<div class="item-actions">
|
||||||
<button class="btn btn-secondary" onclick="manager.editOpenAIProvider(${index}, ${JSON.stringify(provider).replace(/"/g, '"')})">
|
<button class="btn btn-secondary" onclick="manager.editOpenAIProvider(${index}, ${JSON.stringify(item).replace(/"/g, '"')})">
|
||||||
<i class="fas fa-edit"></i>
|
<i class="fas fa-edit"></i>
|
||||||
</button>
|
</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>
|
<i class="fas fa-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`}).join('');
|
`;}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 显示添加OpenAI提供商模态框
|
// 显示添加OpenAI提供商模态框
|
||||||
@@ -2897,27 +2938,37 @@ class CLIProxyManager {
|
|||||||
const modal = document.getElementById('modal');
|
const modal = document.getElementById('modal');
|
||||||
const modalBody = document.getElementById('modal-body');
|
const modalBody = document.getElementById('modal-body');
|
||||||
|
|
||||||
const apiKeyEntries = provider['api-key-entries'] || [];
|
// 处理两种API密钥格式:新的 api-key-entries 和旧的 api-keys
|
||||||
const apiKeysText = apiKeyEntries.map(entry => entry['api-key'] || '').join('\n');
|
let apiKeyEntries = [];
|
||||||
const proxiesText = apiKeyEntries.map(entry => entry['proxy-url'] || '').join('\n');
|
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 = `
|
modalBody.innerHTML = `
|
||||||
<h3>${i18n.t('ai_providers.openai_edit_modal_title')}</h3>
|
<h3>${i18n.t('ai_providers.openai_edit_modal_title')}</h3>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="edit-provider-name">${i18n.t('ai_providers.openai_edit_modal_name_label')}</label>
|
<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>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="edit-provider-url">${i18n.t('ai_providers.openai_edit_modal_url_label')}</label>
|
<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>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="edit-provider-keys">${i18n.t('ai_providers.openai_edit_modal_keys_label')}</label>
|
<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>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="edit-provider-proxies">${i18n.t('ai_providers.openai_edit_modal_keys_proxy_label')}</label>
|
<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>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>${i18n.t('ai_providers.openai_edit_modal_models_label')}</label>
|
<label>${i18n.t('ai_providers.openai_edit_modal_models_label')}</label>
|
||||||
@@ -2932,7 +2983,7 @@ class CLIProxyManager {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
modal.style.display = 'block';
|
modal.style.display = 'block';
|
||||||
this.populateModelFields('edit-provider-models-wrapper', provider.models || []);
|
this.populateModelFields('edit-provider-models-wrapper', models);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新OpenAI提供商
|
// 更新OpenAI提供商
|
||||||
@@ -2994,14 +3045,14 @@ class CLIProxyManager {
|
|||||||
async loadAuthFiles() {
|
async loadAuthFiles() {
|
||||||
try {
|
try {
|
||||||
const data = await this.makeRequest('/auth-files');
|
const data = await this.makeRequest('/auth-files');
|
||||||
this.renderAuthFiles(data.files || []);
|
await this.renderAuthFiles(data.files || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载认证文件失败:', error);
|
console.error('加载认证文件失败:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 渲染认证文件列表
|
// 渲染认证文件列表
|
||||||
renderAuthFiles(files) {
|
async renderAuthFiles(files) {
|
||||||
const container = document.getElementById('auth-files-list');
|
const container = document.getElementById('auth-files-list');
|
||||||
|
|
||||||
if (files.length === 0) {
|
if (files.length === 0) {
|
||||||
@@ -3015,12 +3066,70 @@ class CLIProxyManager {
|
|||||||
return;
|
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="file-item">
|
||||||
<div class="item-content">
|
<div class="item-content">
|
||||||
<div class="item-title">${file.name}</div>
|
<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_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-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> 成功: ${fileStats.success}
|
||||||
|
</span>
|
||||||
|
<span class="stat-badge stat-failure">
|
||||||
|
<i class="fas fa-times-circle"></i> 失败: ${fileStats.failure}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="item-actions">
|
<div class="item-actions">
|
||||||
<button class="btn btn-primary" onclick="manager.downloadAuthFile('${file.name}')">
|
<button class="btn btn-primary" onclick="manager.downloadAuthFile('${file.name}')">
|
||||||
@@ -3031,7 +3140,8 @@ class CLIProxyManager {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`;
|
||||||
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 格式化文件大小
|
// 格式化文件大小
|
||||||
@@ -3927,8 +4037,8 @@ class CLIProxyManager {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const success = detail.success;
|
const isFailed = detail.failed === true;
|
||||||
if (success === false) {
|
if (isFailed) {
|
||||||
sourceStats[source].failure += 1;
|
sourceStats[source].failure += 1;
|
||||||
} else {
|
} else {
|
||||||
sourceStats[source].success += 1;
|
sourceStats[source].success += 1;
|
||||||
|
|||||||
14
styles.css
14
styles.css
@@ -1445,13 +1445,21 @@ input:checked+.slider:before {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* 列表样式 */
|
/* 列表样式 */
|
||||||
.key-list,
|
.key-list {
|
||||||
.provider-list,
|
|
||||||
.file-list {
|
|
||||||
max-height: 400px;
|
max-height: 400px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.file-list {
|
||||||
|
/* 认证文件列表填满页面,保留版本信息空间 */
|
||||||
|
max-height: calc(100vh - 300px); /* 减去导航栏、padding和版本信息的高度 */
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-list {
|
||||||
|
/* 默认不限制高度,动态设置 */
|
||||||
|
}
|
||||||
|
|
||||||
.key-item,
|
.key-item,
|
||||||
.provider-item,
|
.provider-item,
|
||||||
.file-item {
|
.file-item {
|
||||||
|
|||||||
Reference in New Issue
Block a user