Compare commits

...

5 Commits

Author SHA1 Message Date
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
hkfires
0b9abdf9b1 fix(logs): auto-scroll to latest on full reload 2025-10-20 14:57:15 +08:00
hkfires
a208a484ff feat(config-ui): add YAML config editor with save/reload support 2025-10-19 22:40:08 +08:00
5 changed files with 654 additions and 38 deletions

4
.gitignore vendored
View File

@@ -22,4 +22,6 @@ package-lock.json
Thumbs.db
CLAUDE.md
AGENTS.md
.claude
AGENTS.md
.serena

467
app.js
View File

@@ -26,6 +26,18 @@ class CLIProxyManager {
// 主题管理
this.currentTheme = 'light';
// 配置文件编辑器状态
this.configYamlCache = '';
this.isConfigEditorDirty = false;
this.configEditorElements = {
textarea: null,
editorInstance: null,
saveBtn: null,
reloadBtn: null,
statusEl: null
};
this.lastConfigFetchUrl = null;
this.init();
}
@@ -113,6 +125,8 @@ class CLIProxyManager {
this.setupNavigation();
this.setupLanguageSwitcher();
this.setupThemeSwitcher();
this.setupConfigEditor();
this.updateConfigEditorAvailability();
// loadSettings 将在登录成功后调用
this.updateLoginConnectionInfo();
// 检查主机名,如果不是 localhost 或 127.0.0.1,则隐藏 OAuth 登录框
@@ -840,6 +854,9 @@ class CLIProxyManager {
// 如果点击的是日志查看页面,自动加载日志
if (sectionId === 'logs') {
this.refreshLogs(false);
} else if (sectionId === 'config-management') {
this.loadConfigFileEditor();
this.refreshConfigEditor();
}
});
});
@@ -871,6 +888,272 @@ class CLIProxyManager {
}
}
// 初始化配置文件编辑器
setupConfigEditor() {
const textarea = document.getElementById('config-editor');
const saveBtn = document.getElementById('config-save-btn');
const reloadBtn = document.getElementById('config-reload-btn');
const statusEl = document.getElementById('config-editor-status');
this.configEditorElements = {
textarea,
editorInstance: null,
saveBtn,
reloadBtn,
statusEl
};
if (!textarea || !saveBtn || !reloadBtn || !statusEl) {
return;
}
if (window.CodeMirror) {
const editorInstance = window.CodeMirror.fromTextArea(textarea, {
mode: 'yaml',
theme: 'default',
lineNumbers: true,
indentUnit: 2,
tabSize: 2,
lineWrapping: true,
autoCloseBrackets: true,
extraKeys: {
'Ctrl-/': 'toggleComment',
'Cmd-/': 'toggleComment'
}
});
editorInstance.setSize('100%', '100%');
editorInstance.on('change', () => {
this.isConfigEditorDirty = true;
this.updateConfigEditorStatus('info', i18n.t('config_management.status_dirty'));
});
this.configEditorElements.editorInstance = editorInstance;
} else {
textarea.addEventListener('input', () => {
this.isConfigEditorDirty = true;
this.updateConfigEditorStatus('info', i18n.t('config_management.status_dirty'));
});
}
saveBtn.addEventListener('click', () => this.saveConfigFile());
reloadBtn.addEventListener('click', () => this.loadConfigFileEditor(true));
this.refreshConfigEditor();
}
// 更新配置编辑器可用状态
updateConfigEditorAvailability() {
const { textarea, editorInstance, saveBtn, reloadBtn } = this.configEditorElements;
if ((!textarea && !editorInstance) || !saveBtn || !reloadBtn) {
return;
}
const disabled = !this.isConnected;
if (editorInstance) {
editorInstance.setOption('readOnly', disabled ? 'nocursor' : false);
const wrapper = editorInstance.getWrapperElement();
if (wrapper) {
wrapper.classList.toggle('cm-readonly', disabled);
}
} else if (textarea) {
textarea.disabled = disabled;
}
saveBtn.disabled = disabled;
reloadBtn.disabled = disabled;
if (disabled) {
this.updateConfigEditorStatus('info', i18n.t('config_management.status_disconnected'));
}
this.refreshConfigEditor();
}
refreshConfigEditor() {
const instance = this.configEditorElements && this.configEditorElements.editorInstance;
if (instance && typeof instance.refresh === 'function') {
setTimeout(() => instance.refresh(), 0);
}
}
// 更新配置编辑器状态显示
updateConfigEditorStatus(type, message) {
const statusEl = (this.configEditorElements && this.configEditorElements.statusEl) || document.getElementById('config-editor-status');
if (!statusEl) {
return;
}
statusEl.textContent = message;
statusEl.classList.remove('success', 'error');
if (type === 'success') {
statusEl.classList.add('success');
} else if (type === 'error') {
statusEl.classList.add('error');
}
}
// 加载配置文件内容
async loadConfigFileEditor(forceRefresh = false) {
const { textarea, editorInstance, reloadBtn } = this.configEditorElements;
if (!textarea && !editorInstance) {
return;
}
if (!this.isConnected) {
this.updateConfigEditorStatus('info', i18n.t('config_management.status_disconnected'));
return;
}
if (reloadBtn) {
reloadBtn.disabled = true;
}
this.updateConfigEditorStatus('info', i18n.t('config_management.status_loading'));
try {
const yamlText = await this.fetchConfigFile(forceRefresh);
if (editorInstance) {
editorInstance.setValue(yamlText || '');
if (typeof editorInstance.markClean === 'function') {
editorInstance.markClean();
}
} else if (textarea) {
textarea.value = yamlText || '';
}
this.isConfigEditorDirty = false;
this.updateConfigEditorStatus('success', i18n.t('config_management.status_loaded'));
this.refreshConfigEditor();
} catch (error) {
console.error('加载配置文件失败:', error);
this.updateConfigEditorStatus('error', `${i18n.t('config_management.status_load_failed')}: ${error.message}`);
} finally {
if (reloadBtn) {
reloadBtn.disabled = !this.isConnected;
}
}
}
// 获取配置文件内容
async fetchConfigFile(forceRefresh = false) {
if (!forceRefresh && this.configYamlCache) {
return this.configYamlCache;
}
const requestUrl = '/config.yaml';
try {
const response = await fetch(`${this.apiUrl}${requestUrl}`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${this.managementKey}`,
'Accept': 'application/yaml'
}
});
if (!response.ok) {
const errorText = await response.text().catch(() => '');
const message = errorText || `HTTP ${response.status}`;
throw new Error(message);
}
const contentType = response.headers.get('content-type') || '';
if (!/yaml/i.test(contentType)) {
throw new Error(i18n.t('config_management.error_yaml_not_supported'));
}
const text = await response.text();
this.lastConfigFetchUrl = requestUrl;
this.configYamlCache = text;
return text;
} catch (error) {
throw error instanceof Error ? error : new Error(String(error));
}
}
// 保存配置文件
async saveConfigFile() {
const { textarea, editorInstance, saveBtn, reloadBtn } = this.configEditorElements;
if ((!textarea && !editorInstance) || !saveBtn) {
return;
}
if (!this.isConnected) {
this.updateConfigEditorStatus('error', i18n.t('config_management.status_disconnected'));
return;
}
const yamlText = editorInstance ? editorInstance.getValue() : (textarea ? textarea.value : '');
saveBtn.disabled = true;
if (reloadBtn) {
reloadBtn.disabled = true;
}
this.updateConfigEditorStatus('info', i18n.t('config_management.status_saving'));
try {
try {
await this.writeConfigFile('/config.yaml', yamlText);
this.lastConfigFetchUrl = '/config.yaml';
this.configYamlCache = yamlText;
this.isConfigEditorDirty = false;
if (editorInstance && typeof editorInstance.markClean === 'function') {
editorInstance.markClean();
}
this.showNotification(i18n.t('config_management.save_success'), 'success');
this.updateConfigEditorStatus('success', i18n.t('config_management.status_saved'));
this.clearCache();
await this.loadAllData(true);
return;
} catch (error) {
const errorMessage = `${i18n.t('config_management.status_save_failed')}: ${error.message}`;
this.updateConfigEditorStatus('error', errorMessage);
this.showNotification(errorMessage, 'error');
this.isConfigEditorDirty = true;
}
} finally {
saveBtn.disabled = !this.isConnected;
if (reloadBtn) {
reloadBtn.disabled = !this.isConnected;
}
}
}
// 写入配置文件到指定端点
async writeConfigFile(endpoint, yamlText) {
const response = await fetch(`${this.apiUrl}${endpoint}`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${this.managementKey}`,
'Content-Type': 'application/yaml',
'Accept': 'application/json, text/plain, */*'
},
body: yamlText
});
if (!response.ok) {
const contentType = response.headers.get('content-type') || '';
let errorText = '';
if (contentType.includes('application/json')) {
const data = await response.json().catch(() => ({}));
errorText = data.message || data.error || '';
} else {
errorText = await response.text().catch(() => '');
}
throw new Error(errorText || `HTTP ${response.status}`);
}
const contentType = response.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
const data = await response.json().catch(() => null);
if (data && data.ok === false) {
throw new Error(data.message || data.error || 'Server rejected the update');
}
}
}
// 切换语言
toggleLanguage() {
const currentLang = i18n.currentLanguage;
@@ -1044,6 +1327,8 @@ class CLIProxyManager {
lastUpdate.textContent = new Date().toLocaleString('zh-CN');
this.updateConfigEditorAvailability();
// 更新连接信息显示
this.updateConnectionInfo();
}
@@ -1109,6 +1394,7 @@ class CLIProxyManager {
clearCache() {
this.configCache = null;
this.cacheTimestamp = null;
this.configYamlCache = '';
}
// 启动状态更新定时器
@@ -1147,6 +1433,10 @@ class CLIProxyManager {
// 使用统计需要单独加载
await this.loadUsageStats();
// 加载配置文件编辑器内容
await this.loadConfigFileEditor(forceRefresh);
this.refreshConfigEditor();
console.log('配置加载完成,使用缓存:', !forceRefresh && this.isCacheValid());
} catch (error) {
console.error('加载配置失败:', error);
@@ -1241,6 +1531,9 @@ class CLIProxyManager {
this.loadOpenAIProviders(),
this.loadAuthFiles()
]);
await this.loadConfigFileEditor(true);
this.refreshConfigEditor();
}
// 加载调试设置
@@ -1457,8 +1750,8 @@ class CLIProxyManager {
// 增量加载:追加新日志
this.appendLogs(response.lines, response['line-count'] || 0);
} else if (!incremental && response.lines.length > 0) {
// 全量加载:重新渲染
this.renderLogs(response.lines, response['line-count'] || response.lines.length, false);
// 全量加载:重新渲染,默认滚动到底部显示最新日志
this.renderLogs(response.lines, response['line-count'] || response.lines.length, true);
} else if (!incremental) {
// 全量加载但没有日志
logsContent.innerHTML = '<div class="empty-state"><i class="fas fa-inbox"></i><p data-i18n="logs.empty_title">' +
@@ -1753,8 +2046,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
@@ -1905,7 +2204,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">
@@ -2069,7 +2369,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">
@@ -2272,7 +2574,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">
@@ -2460,7 +2764,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>
@@ -2468,19 +2772,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;
});
@@ -2488,11 +2822,11 @@ 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}
@@ -2503,15 +2837,15 @@ class CLIProxyManager {
</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提供商模态框
@@ -2604,27 +2938,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>
@@ -2639,7 +2983,7 @@ class CLIProxyManager {
`;
modal.style.display = 'block';
this.populateModelFields('edit-provider-models-wrapper', provider.models || []);
this.populateModelFields('edit-provider-models-wrapper', models);
}
// 更新OpenAI提供商
@@ -2701,14 +3045,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) {
@@ -2722,12 +3066,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> 成功: ${fileStats.success}
</span>
<span class="stat-badge stat-failure">
<i class="fas fa-times-circle"></i> 失败: ${fileStats.failure}
</span>
</div>
</div>
<div class="item-actions">
<button class="btn btn-primary" onclick="manager.downloadAuthFile('${file.name}')">
@@ -2738,7 +3140,8 @@ class CLIProxyManager {
</button>
</div>
</div>
`).join('');
`;
}).join('');
}
// 格式化文件大小
@@ -3634,8 +4037,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;

38
i18n.js
View File

@@ -82,6 +82,7 @@ const i18n = {
'nav.ai_providers': 'AI 提供商',
'nav.auth_files': '认证文件',
'nav.usage_stats': '使用统计',
'nav.config_management': '配置管理',
'nav.logs': '日志查看',
'nav.system_info': '系统信息',
@@ -329,6 +330,24 @@ const i18n = {
'logs.upgrade_required_title': '需要升级 CLI Proxy API',
'logs.upgrade_required_desc': '当前服务器版本不支持日志查看功能,请升级到最新版本的 CLI Proxy API 以使用此功能。',
// 配置管理
'config_management.title': '配置管理',
'config_management.editor_title': '配置文件',
'config_management.reload': '重新加载',
'config_management.save': '保存',
'config_management.description': '查看并编辑服务器上的 config.yaml 配置文件。保存前请确认语法正确。',
'config_management.status_idle': '等待操作',
'config_management.status_loading': '加载配置中...',
'config_management.status_loaded': '配置已加载',
'config_management.status_dirty': '有未保存的更改',
'config_management.status_disconnected': '请先连接服务器以加载配置',
'config_management.status_load_failed': '加载失败',
'config_management.status_saving': '正在保存配置...',
'config_management.status_saved': '配置保存完成',
'config_management.status_save_failed': '保存失败',
'config_management.save_success': '配置已保存',
'config_management.error_yaml_not_supported': '服务器未返回 YAML 格式,请确认 /config.yaml 接口可用',
// 系统信息
'system_info.title': '系统信息',
'system_info.connection_status_title': '连接状态',
@@ -478,6 +497,7 @@ const i18n = {
'nav.ai_providers': 'AI Providers',
'nav.auth_files': 'Auth Files',
'nav.usage_stats': 'Usage Statistics',
'nav.config_management': 'Config Management',
'nav.logs': 'Logs Viewer',
'nav.system_info': 'System Info',
@@ -724,6 +744,24 @@ const i18n = {
'logs.upgrade_required_title': 'Please Upgrade CLI Proxy API',
'logs.upgrade_required_desc': 'The current server version does not support the logs viewing feature. Please upgrade to the latest version of CLI Proxy API to use this feature.',
// Config management
'config_management.title': 'Config Management',
'config_management.editor_title': 'Configuration File',
'config_management.reload': 'Reload',
'config_management.save': 'Save',
'config_management.description': 'View and edit the server-side config.yaml file. Validate the syntax before saving.',
'config_management.status_idle': 'Waiting for action',
'config_management.status_loading': 'Loading configuration...',
'config_management.status_loaded': 'Configuration loaded',
'config_management.status_dirty': 'Unsaved changes',
'config_management.status_disconnected': 'Connect to the server to load the configuration',
'config_management.status_load_failed': 'Load failed',
'config_management.status_saving': 'Saving configuration...',
'config_management.status_saved': 'Configuration saved',
'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.',
// System info
'system_info.title': 'System Information',
'system_info.connection_status_title': 'Connection Status',

View File

@@ -7,7 +7,13 @@
<title data-i18n="title.login">CLI Proxy API Management Center</title>
<link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.css">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/yaml/yaml.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/addon/edit/closebrackets.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/addon/comment/comment.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/js-yaml@4.1.0/dist/js-yaml.min.js"></script>
<script src="i18n.js"></script>
</head>
@@ -171,6 +177,9 @@
<li data-tooltip="使用统计"><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">
<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">
<i class="fas fa-scroll"></i> <span data-i18n="nav.logs">日志查看</span>
</a></li>
@@ -766,6 +775,32 @@
</div>
</section>
<!-- 配置管理 -->
<section id="config-management" class="content-section">
<h2 data-i18n="config_management.title">配置管理</h2>
<div class="card">
<div class="card-header">
<h3><i class="fas fa-file-code"></i> <span data-i18n="config_management.editor_title">配置文件</span></h3>
<div class="editor-actions">
<button id="config-reload-btn" class="btn btn-secondary">
<i class="fas fa-sync-alt"></i> <span data-i18n="config_management.reload">重新加载</span>
</button>
<button id="config-save-btn" class="btn btn-primary">
<i class="fas fa-save"></i> <span data-i18n="config_management.save">保存</span>
</button>
</div>
</div>
<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>
<div id="config-editor-status" class="editor-status" data-i18n="config_management.status_idle">等待操作</div>
</div>
</div>
</div>
</section>
<!-- 系统信息 -->
<section id="system-info" class="content-section">
<h2 data-i18n="system_info.title">系统信息</h2>
@@ -861,4 +896,4 @@
<script src="app.js"></script>
</body>
</html>
</html>

View File

@@ -1278,6 +1278,133 @@ textarea::placeholder {
height: 0;
}
/* 配置管理编辑器 */
.editor-actions {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.editor-actions .btn {
white-space: nowrap;
}
.yaml-editor-container {
display: flex;
flex-direction: column;
gap: 12px;
}
#config-management .card {
display: flex;
flex-direction: column;
min-height: calc(100vh - 360px);
margin-bottom: 12px;
}
#config-management .card-content {
flex: 1;
display: flex;
flex-direction: column;
}
#config-management .yaml-editor-container {
flex: 1;
display: flex;
flex-direction: column;
}
.yaml-editor {
width: 100%;
flex: 1;
min-height: 360px;
border: 1px solid var(--border-primary);
border-radius: 6px;
padding: 12px 14px;
background: var(--bg-secondary);
color: var(--text-primary);
overflow-y: auto;
font-family: 'SFMono-Regular', 'Consolas', 'Liberation Mono', 'Courier New', monospace;
line-height: 1.5;
resize: vertical;
}
#config-management .CodeMirror {
flex: 1;
height: 100%;
font-family: 'SFMono-Regular', 'Consolas', 'Liberation Mono', 'Courier New', monospace;
font-size: 14px;
line-height: 1.5;
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 6px;
overflow: hidden;
}
#config-management .CodeMirror-scroll {
min-height: 0;
max-height: calc(100vh - 440px);
overflow-y: auto;
}
#config-management .CodeMirror.cm-s-default {
background: var(--bg-secondary);
color: var(--text-primary);
}
#config-management .CodeMirror-gutters {
background: var(--bg-secondary);
border-right: 1px solid var(--border-primary);
}
#config-management .CodeMirror-linenumber {
color: var(--text-tertiary);
}
#config-management .CodeMirror .CodeMirror-lines {
padding: 12px;
}
#config-management .CodeMirror .cm-comment {
color: #6b7280;
}
[data-theme="dark"] #config-management .CodeMirror.cm-s-default {
background: var(--bg-secondary);
color: var(--text-primary);
}
[data-theme="dark"] #config-management .CodeMirror-gutters {
background: var(--bg-secondary);
border-right: 1px solid var(--border-secondary);
}
[data-theme="dark"] #config-management .CodeMirror .cm-comment {
color: #9ca3af;
}
#config-management .CodeMirror.cm-readonly {
opacity: 0.6;
}
.editor-status {
font-size: 13px;
color: var(--text-quaternary);
display: flex;
align-items: center;
gap: 6px;
min-height: 18px;
}
.editor-status.success {
color: #059669;
}
.editor-status.error {
color: #dc2626;
}
.slider {
position: absolute;
cursor: pointer;
@@ -1318,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 {
@@ -2924,4 +3059,7 @@ input:checked+.slider:before {
.stat-badge i {
font-size: 11px !important;
}
}
}
#config-management .CodeMirror .CodeMirror-lines {
padding: 12px;
}