Compare commits

...

2 Commits

Author SHA1 Message Date
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 503 additions and 5 deletions

4
.gitignore vendored
View File

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

297
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">' +

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;
@@ -2924,4 +3051,7 @@ input:checked+.slider:before {
.stat-badge i {
font-size: 11px !important;
}
}
}
#config-management .CodeMirror .CodeMirror-lines {
padding: 12px;
}