diff --git a/.gitignore b/.gitignore index cfb9c54..00f5013 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,5 @@ Thumbs.db CLAUDE.md .claude AGENTS.md +.codex .serena \ No newline at end of file diff --git a/app.js b/app.js index 989f404..e10e279 100644 --- a/app.js +++ b/app.js @@ -1,3 +1,16 @@ +import { themeModule } from './src/modules/theme.js'; +import { navigationModule } from './src/modules/navigation.js'; +import { languageModule } from './src/modules/language.js'; +import { loginModule } from './src/modules/login.js'; +import { configEditorModule } from './src/modules/config-editor.js'; +import { logsModule } from './src/modules/logs.js'; +import { apiKeysModule } from './src/modules/api-keys.js'; +import { authFilesModule } from './src/modules/auth-files.js'; +import { oauthModule } from './src/modules/oauth.js'; +import { usageModule } from './src/modules/usage.js'; +import { settingsModule } from './src/modules/settings.js'; +import { aiProvidersModule } from './src/modules/ai-providers.js'; + // CLI Proxy API 管理界面 JavaScript class CLIProxyManager { constructor() { @@ -100,74 +113,6 @@ class CLIProxyManager { }; } - // 初始化主题 - initializeTheme() { - // 从本地存储获取用户偏好主题 - const savedTheme = localStorage.getItem('preferredTheme'); - if (savedTheme && ['light', 'dark'].includes(savedTheme)) { - this.currentTheme = savedTheme; - } else { - if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { - this.currentTheme = 'dark'; - } else { - this.currentTheme = 'light'; - } - } - - this.applyTheme(this.currentTheme); - this.updateThemeButtons(); - - // 监听系统主题变化 - if (window.matchMedia) { - window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => { - if (!localStorage.getItem('preferredTheme')) { - this.currentTheme = e.matches ? 'dark' : 'light'; - this.applyTheme(this.currentTheme); - this.updateThemeButtons(); - } - }); - } - } - - // 应用主题 - applyTheme(theme) { - if (theme === 'dark') { - document.documentElement.setAttribute('data-theme', 'dark'); - } else { - document.documentElement.removeAttribute('data-theme'); - } - this.currentTheme = theme; - } - - // 切换主题 - toggleTheme() { - const newTheme = this.currentTheme === 'light' ? 'dark' : 'light'; - this.applyTheme(newTheme); - this.updateThemeButtons(); - localStorage.setItem('preferredTheme', newTheme); - } - - // 更新主题按钮状态 - updateThemeButtons() { - const loginThemeBtn = document.getElementById('theme-toggle'); - const mainThemeBtn = document.getElementById('theme-toggle-main'); - - const updateButton = (btn) => { - if (!btn) return; - const icon = btn.querySelector('i'); - if (this.currentTheme === 'dark') { - icon.className = 'fas fa-sun'; - btn.title = i18n.t('theme.switch_to_light'); - } else { - icon.className = 'fas fa-moon'; - btn.title = i18n.t('theme.switch_to_dark'); - } - }; - - updateButton(loginThemeBtn); - updateButton(mainThemeBtn); - } - init() { this.initializeTheme(); this.checkLoginStatus(); @@ -223,290 +168,7 @@ class CLIProxyManager { } // 检查登录状态 - async checkLoginStatus() { - // 检查是否有保存的连接信息 - const savedBase = localStorage.getItem('apiBase'); - const savedKey = localStorage.getItem('managementKey'); - const wasLoggedIn = localStorage.getItem('isLoggedIn') === 'true'; - - // 如果有完整的连接信息且之前已登录,尝试自动登录 - if (savedBase && savedKey && wasLoggedIn) { - try { - console.log(i18n.t('auto_login.title')); - this.showAutoLoginLoading(); - await this.attemptAutoLogin(savedBase, savedKey); - return; // 自动登录成功,不显示登录页面 - } catch (error) { - console.log(`${i18n.t('notification.login_failed')}: ${error.message}`); - // 清除无效的登录状态 - localStorage.removeItem('isLoggedIn'); - this.hideAutoLoginLoading(); - } - } - - // 如果没有连接信息或自动登录失败,显示登录页面 - this.showLoginPage(); - this.loadLoginSettings(); - } - - // 显示自动登录加载页面 - showAutoLoginLoading() { - document.getElementById('auto-login-loading').style.display = 'flex'; - document.getElementById('login-page').style.display = 'none'; - document.getElementById('main-page').style.display = 'none'; - } - - // 隐藏自动登录加载页面 - hideAutoLoginLoading() { - document.getElementById('auto-login-loading').style.display = 'none'; - } - - // 尝试自动登录 - async attemptAutoLogin(apiBase, managementKey) { - try { - // 设置API基础地址和密钥 - this.setApiBase(apiBase); - this.managementKey = managementKey; - - // 恢复代理设置(如果有) - const savedProxy = localStorage.getItem('proxyUrl'); - if (savedProxy) { - // 代理设置会在后续的API请求中自动使用 - } - - // 测试连接 - await this.testConnection(); - - // 自动登录成功 - this.isLoggedIn = true; - this.hideAutoLoginLoading(); - this.showMainPage(); - - console.log(i18n.t('auto_login.title')); - return true; - } catch (error) { - console.error('自动登录失败:', error); - // 重置状态 - this.isLoggedIn = false; - this.isConnected = false; - throw error; - } - } - - // 显示登录页面 - showLoginPage() { - document.getElementById('login-page').style.display = 'flex'; - document.getElementById('main-page').style.display = 'none'; - this.isLoggedIn = false; - this.updateLoginConnectionInfo(); - } - - // 显示主页面 - showMainPage() { - document.getElementById('login-page').style.display = 'none'; - document.getElementById('main-page').style.display = 'block'; - this.isLoggedIn = true; - this.updateConnectionInfo(); - } - - // 登录验证 - async login(apiBase, managementKey) { - try { - // 设置API基础地址和密钥 - this.setApiBase(apiBase); - this.managementKey = managementKey; - localStorage.setItem('managementKey', this.managementKey); - - // 测试连接并加载所有数据 - await this.testConnection(); - - // 登录成功 - this.isLoggedIn = true; - localStorage.setItem('isLoggedIn', 'true'); - - this.showMainPage(); - // 不需要再调用loadSettings,因为内部状态已经在上面设置了 - - return true; - } catch (error) { - console.error('登录失败:', error); - throw error; - } - } - - // 登出 - logout() { - this.isLoggedIn = false; - this.isConnected = false; - this.clearCache(); - this.stopStatusUpdateTimer(); - - // 清除本地存储 - localStorage.removeItem('isLoggedIn'); - localStorage.removeItem('managementKey'); - - this.showLoginPage(); - } - // 处理登录表单提交 - async handleLogin() { - const apiBaseInput = document.getElementById('login-api-base'); - const managementKeyInput = document.getElementById('login-management-key'); - const managementKey = managementKeyInput ? managementKeyInput.value.trim() : ''; - - if (!managementKey) { - this.showLoginError(i18n.t('login.error_required')); - return; - } - - if (apiBaseInput && apiBaseInput.value.trim()) { - this.setApiBase(apiBaseInput.value.trim()); - } - - const submitBtn = document.getElementById('login-submit'); - const originalText = submitBtn ? submitBtn.innerHTML : ''; - - try { - if (submitBtn) { - submitBtn.innerHTML = `
${i18n.t('login.submitting')}`; - submitBtn.disabled = true; - } - this.hideLoginError(); - - this.managementKey = managementKey; - localStorage.setItem('managementKey', this.managementKey); - - await this.login(this.apiBase, this.managementKey); - } catch (error) { - this.showLoginError(`${i18n.t('login.error_title')}: ${error.message}`); - } finally { - if (submitBtn) { - submitBtn.innerHTML = originalText; - submitBtn.disabled = false; - } - } - } - - // 切换登录页面密钥可见性 - toggleLoginKeyVisibility(button) { - const inputGroup = button.closest('.input-group'); - const keyInput = inputGroup.querySelector('input[type="password"], input[type="text"]'); - - if (keyInput.type === 'password') { - keyInput.type = 'text'; - button.innerHTML = ''; - } else { - keyInput.type = 'password'; - button.innerHTML = ''; - } - } - - // 显示登录错误 - showLoginError(message) { - const errorDiv = document.getElementById('login-error'); - const errorMessage = document.getElementById('login-error-message'); - - errorMessage.textContent = message; - errorDiv.style.display = 'flex'; - } - - // 隐藏登录错误 - hideLoginError() { - const errorDiv = document.getElementById('login-error'); - errorDiv.style.display = 'none'; - } - - // 更新连接信息显示 - updateConnectionInfo() { - const apiUrlElement = document.getElementById('display-api-url'); - const statusElement = document.getElementById('display-connection-status'); - - // 显示API地址 - if (apiUrlElement) { - apiUrlElement.textContent = this.apiBase || '-'; - } - - // 显示密钥(遮蔽显示) - - // 显示连接状态 - if (statusElement) { - let statusHtml = ''; - if (this.isConnected) { - statusHtml = ` ${i18n.t('common.connected')}`; - } else { - statusHtml = ` ${i18n.t('common.disconnected')}`; - } - statusElement.innerHTML = statusHtml; - } - } - - - // 加载登录页面设置 - loadLoginSettings() { - const savedBase = localStorage.getItem('apiBase'); - const savedKey = localStorage.getItem('managementKey'); - const loginKeyInput = document.getElementById('login-management-key'); - const apiBaseInput = document.getElementById('login-api-base'); - - if (savedBase) { - this.setApiBase(savedBase); - } else { - this.setApiBase(this.detectApiBaseFromLocation()); - } - - if (apiBaseInput) { - apiBaseInput.value = this.apiBase || ''; - } - - if (loginKeyInput && savedKey) { - loginKeyInput.value = savedKey; - } - - this.setupLoginAutoSave(); - } - - setupLoginAutoSave() { - const loginKeyInput = document.getElementById('login-management-key'); - const apiBaseInput = document.getElementById('login-api-base'); - const resetButton = document.getElementById('login-reset-api-base'); - - const saveKey = (val) => { - if (val.trim()) { - this.managementKey = val; - localStorage.setItem('managementKey', this.managementKey); - } - }; - const saveKeyDebounced = this.debounce(saveKey, 500); - - if (loginKeyInput) { - loginKeyInput.addEventListener('change', (e) => saveKey(e.target.value)); - loginKeyInput.addEventListener('input', (e) => saveKeyDebounced(e.target.value)); - } - - if (apiBaseInput) { - const persistBase = (val) => { - const normalized = this.normalizeBase(val); - if (normalized) { - this.setApiBase(normalized); - } - }; - const persistBaseDebounced = this.debounce(persistBase, 500); - - apiBaseInput.addEventListener('change', (e) => persistBase(e.target.value)); - apiBaseInput.addEventListener('input', (e) => persistBaseDebounced(e.target.value)); - } - - if (resetButton) { - resetButton.addEventListener('click', () => { - const detected = this.detectApiBaseFromLocation(); - this.setApiBase(detected); - if (apiBaseInput) { - apiBaseInput.value = detected; - } - }); - } - } - // 事件绑定 bindEvents() { // 登录相关(安全绑定) @@ -836,427 +498,8 @@ class CLIProxyManager { }); } - // 切换移动端侧边栏 - toggleMobileSidebar() { - const sidebar = document.getElementById('sidebar'); - const overlay = document.getElementById('sidebar-overlay'); - const layout = document.getElementById('layout-container'); - const mainWrapper = document.getElementById('main-wrapper'); - - if (sidebar && overlay) { - const isOpen = sidebar.classList.toggle('mobile-open'); - overlay.classList.toggle('active'); - if (layout) { - layout.classList.toggle('sidebar-open', isOpen); - } - if (mainWrapper) { - mainWrapper.classList.toggle('sidebar-open', isOpen); - } - } - } - - // 关闭移动端侧边栏 - closeMobileSidebar() { - const sidebar = document.getElementById('sidebar'); - const overlay = document.getElementById('sidebar-overlay'); - const layout = document.getElementById('layout-container'); - const mainWrapper = document.getElementById('main-wrapper'); - - if (sidebar && overlay) { - sidebar.classList.remove('mobile-open'); - overlay.classList.remove('active'); - if (layout) { - layout.classList.remove('sidebar-open'); - } - if (mainWrapper) { - mainWrapper.classList.remove('sidebar-open'); - } - } - } - - // 切换侧边栏收起/展开状态 - toggleSidebar() { - const sidebar = document.getElementById('sidebar'); - const layout = document.getElementById('layout-container'); - - if (sidebar && layout) { - const isCollapsed = sidebar.classList.toggle('collapsed'); - layout.classList.toggle('sidebar-collapsed', isCollapsed); - - // 保存状态到本地存储 - localStorage.setItem('sidebarCollapsed', isCollapsed ? 'true' : 'false'); - - // 更新按钮提示文本 - const toggleBtn = document.getElementById('sidebar-toggle-btn-desktop'); - if (toggleBtn) { - toggleBtn.setAttribute('data-i18n-title', isCollapsed ? 'sidebar.toggle_expand' : 'sidebar.toggle_collapse'); - toggleBtn.title = i18n.t(isCollapsed ? 'sidebar.toggle_expand' : 'sidebar.toggle_collapse'); - } - } - } - - // 恢复侧边栏状态 - restoreSidebarState() { - // 只在桌面端恢复侧栏状态 - if (window.innerWidth > 1024) { - const savedState = localStorage.getItem('sidebarCollapsed'); - if (savedState === 'true') { - const sidebar = document.getElementById('sidebar'); - const layout = document.getElementById('layout-container'); - - if (sidebar && layout) { - sidebar.classList.add('collapsed'); - layout.classList.add('sidebar-collapsed'); - - // 更新按钮提示文本 - const toggleBtn = document.getElementById('sidebar-toggle-btn-desktop'); - if (toggleBtn) { - toggleBtn.setAttribute('data-i18n-title', 'sidebar.toggle_expand'); - toggleBtn.title = i18n.t('sidebar.toggle_expand'); - } - } - } - } - } - - // 设置导航 - setupNavigation() { - const navItems = document.querySelectorAll('.nav-item'); - navItems.forEach(item => { - item.addEventListener('click', (e) => { - e.preventDefault(); - - // 移除所有活动状态 - navItems.forEach(nav => nav.classList.remove('active')); - document.querySelectorAll('.content-section').forEach(section => section.classList.remove('active')); - - // 添加活动状态 - item.classList.add('active'); - const sectionId = item.getAttribute('data-section'); - document.getElementById(sectionId).classList.add('active'); - - // 如果点击的是日志查看页面,自动加载日志 - if (sectionId === 'logs') { - this.refreshLogs(false); - } else if (sectionId === 'config-management') { - this.loadConfigFileEditor(); - this.refreshConfigEditor(); - } - }); - }); - } - - // 设置语言切换 - setupLanguageSwitcher() { - const loginToggle = document.getElementById('language-toggle'); - const mainToggle = document.getElementById('language-toggle-main'); - - if (loginToggle) { - loginToggle.addEventListener('click', () => this.toggleLanguage()); - } - if (mainToggle) { - mainToggle.addEventListener('click', () => this.toggleLanguage()); - } - } - - // 设置主题切换 - setupThemeSwitcher() { - const loginToggle = document.getElementById('theme-toggle'); - const mainToggle = document.getElementById('theme-toggle-main'); - - if (loginToggle) { - loginToggle.addEventListener('click', () => this.toggleTheme()); - } - if (mainToggle) { - mainToggle.addEventListener('click', () => this.toggleTheme()); - } - } // 初始化配置文件编辑器 - 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(); - this.lastEditorConnectionState = this.isConnected; - } - - 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; - const newLang = currentLang === 'zh-CN' ? 'en-US' : 'zh-CN'; - i18n.setLanguage(newLang); - - // 更新主题按钮文本 - this.updateThemeButtons(); - - // 更新连接状态显示 - this.updateConnectionStatus(); - - // 重新加载所有数据以更新动态内容 - if (this.isLoggedIn && this.isConnected) { - this.loadAllData(true); - } - } - // 规范化基础地址,移除尾部斜杠与 /v0/management normalizeBase(input) { let base = (input || '').trim(); @@ -1651,769 +894,6 @@ class CLIProxyManager { await this.renderOpenAIProviders(Array.isArray(config['openai-compatibility']) ? config['openai-compatibility'] : [], keyStats); } - // 回退方法:原来的逐个加载方式 - async loadAllDataLegacy() { - await Promise.all([ - this.loadDebugSettings(), - this.loadProxySettings(), - this.loadRetrySettings(), - this.loadQuotaSettings(), - this.loadUsageStatisticsSettings(), - this.loadRequestLogSetting(), - this.loadWsAuthSetting(), - this.loadApiKeys(), - this.loadGeminiKeys(), - this.loadCodexKeys(), - this.loadClaudeKeys(), - this.loadOpenAIProviders(), - this.loadAuthFiles() - ]); - - await this.loadConfigFileEditor(true); - this.refreshConfigEditor(); - } - - // 加载调试设置 - async loadDebugSettings() { - try { - const config = await this.getConfig(); - if (config.debug !== undefined) { - document.getElementById('debug-toggle').checked = config.debug; - } - } catch (error) { - console.error('加载调试设置失败:', error); - } - } - - // 更新调试设置 - async updateDebug(enabled) { - try { - await this.makeRequest('/debug', { - method: 'PUT', - body: JSON.stringify({ value: enabled }) - }); - this.clearCache(); // 清除缓存 - this.showNotification(i18n.t('notification.debug_updated'), 'success'); - } catch (error) { - this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error'); - // 恢复原状态 - document.getElementById('debug-toggle').checked = !enabled; - } - } - - // 加载代理设置 - async loadProxySettings() { - try { - const config = await this.getConfig(); - if (config['proxy-url'] !== undefined) { - document.getElementById('proxy-url').value = config['proxy-url'] || ''; - } - } catch (error) { - console.error('加载代理设置失败:', error); - } - } - - // 更新代理URL - async updateProxyUrl() { - const proxyUrl = document.getElementById('proxy-url').value.trim(); - - try { - await this.makeRequest('/proxy-url', { - method: 'PUT', - body: JSON.stringify({ value: proxyUrl }) - }); - this.clearCache(); // 清除缓存 - this.showNotification(i18n.t('notification.proxy_updated'), 'success'); - } catch (error) { - this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error'); - } - } - - // 清空代理URL - async clearProxyUrl() { - try { - await this.makeRequest('/proxy-url', { method: 'DELETE' }); - document.getElementById('proxy-url').value = ''; - this.clearCache(); // 清除缓存 - this.showNotification(i18n.t('notification.proxy_cleared'), 'success'); - } catch (error) { - this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error'); - } - } - - // 加载重试设置 - async loadRetrySettings() { - try { - const config = await this.getConfig(); - if (config['request-retry'] !== undefined) { - document.getElementById('request-retry').value = config['request-retry']; - } - } catch (error) { - console.error('加载重试设置失败:', error); - } - } - - // 更新请求重试 - async updateRequestRetry() { - const retryCount = parseInt(document.getElementById('request-retry').value); - - try { - await this.makeRequest('/request-retry', { - method: 'PUT', - body: JSON.stringify({ value: retryCount }) - }); - this.clearCache(); // 清除缓存 - this.showNotification(i18n.t('notification.retry_updated'), 'success'); - } catch (error) { - this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error'); - } - } - - // 加载配额设置 - async loadQuotaSettings() { - try { - const config = await this.getConfig(); - if (config['quota-exceeded']) { - if (config['quota-exceeded']['switch-project'] !== undefined) { - document.getElementById('switch-project-toggle').checked = config['quota-exceeded']['switch-project']; - } - if (config['quota-exceeded']['switch-preview-model'] !== undefined) { - document.getElementById('switch-preview-model-toggle').checked = config['quota-exceeded']['switch-preview-model']; - } - } - } catch (error) { - console.error('加载配额设置失败:', error); - } - } - - // 加载使用统计设置 - async loadUsageStatisticsSettings() { - try { - const config = await this.getConfig(); - if (config['usage-statistics-enabled'] !== undefined) { - const usageToggle = document.getElementById('usage-statistics-enabled-toggle'); - if (usageToggle) { - usageToggle.checked = config['usage-statistics-enabled']; - } - } - } catch (error) { - console.error('加载使用统计设置失败:', error); - } - } - - // 加载请求日志设置 - async loadRequestLogSetting() { - try { - const config = await this.getConfig(); - if (config['request-log'] !== undefined) { - const requestLogToggle = document.getElementById('request-log-toggle'); - if (requestLogToggle) { - requestLogToggle.checked = config['request-log']; - } - } - } catch (error) { - console.error('加载请求日志设置失败:', error); - } - } - - // 加载 WebSocket 鉴权设置 - async loadWsAuthSetting() { - try { - const config = await this.getConfig(); - if (config['ws-auth'] !== undefined) { - const wsAuthToggle = document.getElementById('ws-auth-toggle'); - if (wsAuthToggle) { - wsAuthToggle.checked = config['ws-auth']; - } - } - } catch (error) { - console.error('加载 WebSocket 鉴权设置失败:', error); - } - } - - // 更新使用统计设置 - async updateUsageStatisticsEnabled(enabled) { - try { - await this.makeRequest('/usage-statistics-enabled', { - method: 'PUT', - body: JSON.stringify({ value: enabled }) - }); - this.clearCache(); - this.showNotification(i18n.t('notification.usage_statistics_updated'), 'success'); - } catch (error) { - this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error'); - const usageToggle = document.getElementById('usage-statistics-enabled-toggle'); - if (usageToggle) { - usageToggle.checked = !enabled; - } - } - } - - // 更新请求日志设置 - async updateRequestLog(enabled) { - try { - await this.makeRequest('/request-log', { - method: 'PUT', - body: JSON.stringify({ value: enabled }) - }); - this.clearCache(); - this.showNotification(i18n.t('notification.request_log_updated'), 'success'); - } catch (error) { - this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error'); - const requestLogToggle = document.getElementById('request-log-toggle'); - if (requestLogToggle) { - requestLogToggle.checked = !enabled; - } - } - } - - // 更新 WebSocket 鉴权设置 - async updateWsAuth(enabled) { - try { - await this.makeRequest('/ws-auth', { - method: 'PUT', - body: JSON.stringify({ value: enabled }) - }); - this.clearCache(); - this.showNotification(i18n.t('notification.ws_auth_updated'), 'success'); - } catch (error) { - this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error'); - const wsAuthToggle = document.getElementById('ws-auth-toggle'); - if (wsAuthToggle) { - wsAuthToggle.checked = !enabled; - } - } - } - - // 更新日志记录到文件设置 - async updateLoggingToFile(enabled) { - try { - await this.makeRequest('/logging-to-file', { - method: 'PUT', - body: JSON.stringify({ value: enabled }) - }); - this.clearCache(); - this.showNotification(i18n.t('notification.logging_to_file_updated'), 'success'); - // 显示或隐藏日志查看栏目 - this.toggleLogsNavItem(enabled); - // 如果启用了日志记录,自动刷新日志 - if (enabled) { - setTimeout(() => this.refreshLogs(), 500); - } - } catch (error) { - this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error'); - const loggingToggle = document.getElementById('logging-to-file-toggle'); - if (loggingToggle) { - loggingToggle.checked = !enabled; - } - } - } - - // 切换日志查看栏目的显示/隐藏 - toggleLogsNavItem(show) { - const logsNavItem = document.getElementById('logs-nav-item'); - if (logsNavItem) { - logsNavItem.style.display = show ? '' : 'none'; - } - } - - // 刷新日志 - async refreshLogs(incremental = false) { - const logsContent = document.getElementById('logs-content'); - if (!logsContent) return; - - try { - // 如果是增量加载且没有时间戳,则转为全量加载 - if (incremental && !this.latestLogTimestamp) { - incremental = false; - } - - // 全量加载时显示加载提示 - if (!incremental) { - logsContent.innerHTML = '
' + i18n.t('logs.loading') + '
'; - } - - // 构建请求 URL - let url = '/logs'; - if (incremental && this.latestLogTimestamp) { - url += `?after=${this.latestLogTimestamp}`; - } - - const response = await this.makeRequest(url, { - method: 'GET' - }); - - if (response && response.lines) { - // 更新最新时间戳 - if (response['latest-timestamp']) { - this.latestLogTimestamp = response['latest-timestamp']; - } - - if (incremental && response.lines.length > 0) { - // 增量加载:追加新日志 - 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, true); - } else if (!incremental) { - // 全量加载但没有日志 - logsContent.innerHTML = '

' + - i18n.t('logs.empty_title') + '

' + - i18n.t('logs.empty_desc') + '

'; - this.latestLogTimestamp = null; - } - // 增量加载但没有新日志,不做任何操作 - } else if (!incremental) { - logsContent.innerHTML = '

' + - i18n.t('logs.empty_title') + '

' + - i18n.t('logs.empty_desc') + '

'; - this.latestLogTimestamp = null; - } - } catch (error) { - console.error('加载日志失败:', error); - if (!incremental) { - // 检查是否是 404 错误(API 不存在) - const is404 = error.message && (error.message.includes('404') || error.message.includes('Not Found')); - - if (is404) { - // API 不存在,提示升级 - logsContent.innerHTML = '

' + - i18n.t('logs.upgrade_required_title') + '

' + - i18n.t('logs.upgrade_required_desc') + '

'; - } else { - // 其他错误 - logsContent.innerHTML = '

' + - i18n.t('logs.load_error') + '

' + error.message + '

'; - } - } - } - } - - // 渲染日志内容 - renderLogs(lines, lineCount, scrollToBottom = true) { - const logsContent = document.getElementById('logs-content'); - if (!logsContent) return; - - if (!lines || lines.length === 0) { - this.displayedLogLines = []; - logsContent.innerHTML = '

' + - i18n.t('logs.empty_title') + '

' + - i18n.t('logs.empty_desc') + '

'; - return; - } - - // 过滤掉 /v0/management/ 相关的日志 - const filteredLines = lines.filter(line => !line.includes('/v0/management/')); - - // 限制前端显示的最大行数 - let displayedLines = filteredLines; - if (filteredLines.length > this.maxDisplayLogLines) { - const linesToRemove = filteredLines.length - this.maxDisplayLogLines; - displayedLines = filteredLines.slice(linesToRemove); - } - - this.displayedLogLines = displayedLines.slice(); - - const displayedLineCount = this.displayedLogLines.length; - logsContent.innerHTML = ` -
- ${displayedLineCount} ${i18n.t('logs.lines')} -
-
${this.buildLogsHtml(this.displayedLogLines)}
- `; - - // 自动滚动到底部 - if (scrollToBottom) { - const logsTextElement = logsContent.querySelector('.logs-text'); - if (logsTextElement) { - logsTextElement.scrollTop = logsTextElement.scrollHeight; - } - } - } - - // 追加新日志(增量加载) - appendLogs(newLines, totalLineCount) { - const logsContent = document.getElementById('logs-content'); - if (!logsContent) return; - - if (!newLines || newLines.length === 0) { - return; - } - - const logsTextElement = logsContent.querySelector('.logs-text'); - const logsInfoElement = logsContent.querySelector('.logs-info'); - - // 过滤掉 /v0/management/ 相关的日志 - const filteredNewLines = newLines.filter(line => !line.includes('/v0/management/')); - if (filteredNewLines.length === 0) { - return; // 如果过滤后没有新日志,直接返回 - } - - if (!logsTextElement) { - this.renderLogs(filteredNewLines, totalLineCount || filteredNewLines.length, true); - return; - } - - // 检查用户是否正在查看底部(判断是否需要自动滚动) - const isAtBottom = logsTextElement.scrollHeight - logsTextElement.scrollTop - logsTextElement.clientHeight < 50; - - this.displayedLogLines = this.displayedLogLines.concat(filteredNewLines); - if (this.displayedLogLines.length > this.maxDisplayLogLines) { - this.displayedLogLines = this.displayedLogLines.slice(this.displayedLogLines.length - this.maxDisplayLogLines); - } - - logsTextElement.innerHTML = this.buildLogsHtml(this.displayedLogLines); - - // 更新行数统计(只显示实际显示的行数,最多 10000 行) - if (logsInfoElement) { - const displayedLines = this.displayedLogLines.length; - logsInfoElement.innerHTML = ` ${displayedLines} ${i18n.t('logs.lines')}`; - } - - // 如果用户在底部,自动滚动到新内容 - if (isAtBottom) { - logsTextElement.scrollTop = logsTextElement.scrollHeight; - } - } - - // 根据日志内容构造高亮 HTML - buildLogsHtml(lines) { - if (!lines || lines.length === 0) { - return ''; - } - return lines.map(line => { - // 先过滤掉 [GIN] 2025/11/03 - 18:32:59 部分 - // 匹配模式:[GIN] 后面跟着日期 - 时间 - let processedLine = line.replace(/\[GIN\]\s+\d{4}\/\d{2}\/\d{2}\s+-\s+\d{2}:\d{2}:\d{2}\s+/g, ''); - - // 创建标记数组来跟踪需要高亮的位置 - const highlights = []; - - // 1. 检测 HTTP 状态码 - const statusInfo = this.detectHttpStatus(line); - if (statusInfo) { - const statusPattern = new RegExp(`\\b${statusInfo.code}\\b`); - const match = statusPattern.exec(processedLine); - if (match) { - highlights.push({ - start: match.index, - end: match.index + match[0].length, - className: `log-status-tag log-status-${statusInfo.bucket}`, - priority: 10 - }); - } - } - - // 2. 时间戳(只匹配标准格式,排除日期和时间被分隔符分开的情况) - // 匹配格式: - // - 2024-01-01 12:00:00 或 2024/01/01 12:00:00(中间只有一个空格) - // - 2024-01-01T12:00:00 - // - [12:00:00] - // 排除:2024/01/01 - 12:00:00(中间有 - 分隔符) - const timestampPattern = /\d{4}[-/]\d{2}[-/]\d{2}[T]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?|\[\d{2}:\d{2}:\d{2}\]/g; - let match; - while ((match = timestampPattern.exec(processedLine)) !== null) { - highlights.push({ - start: match.index, - end: match.index + match[0].length, - className: 'log-timestamp', - priority: 5 - }); - } - - // 2b. 只匹配在方括号内的完整日期时间(如 [2025-11-03 18:23:14]) - const bracketTimestampPattern = /\[\d{4}[-/]\d{2}[-/]\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?\]/g; - while ((match = bracketTimestampPattern.exec(processedLine)) !== null) { - highlights.push({ - start: match.index, - end: match.index + match[0].length, - className: 'log-timestamp', - priority: 5 - }); - } - - // 3. 日志级别(只匹配在方括号内的,避免匹配路径中的关键字) - const levelPattern = /\[(ERROR|ERRO|ERR|FATAL|CRITICAL|CRIT|WARN|WARNING|INFO|DEBUG|TRACE|PANIC)\]/gi; - while ((match = levelPattern.exec(processedLine)) !== null) { - const level = match[1].toUpperCase(); - let className = 'log-level'; - if (['ERROR', 'ERRO', 'ERR', 'FATAL', 'CRITICAL', 'CRIT', 'PANIC'].includes(level)) { - className += ' log-level-error'; - } else if (['WARN', 'WARNING'].includes(level)) { - className += ' log-level-warn'; - } else if (level === 'INFO') { - className += ' log-level-info'; - } else if (['DEBUG', 'TRACE'].includes(level)) { - className += ' log-level-debug'; - } - highlights.push({ - start: match.index, - end: match.index + match[0].length, - className: className, - priority: 8 - }); - } - - // 4. HTTP 方法 - const methodPattern = /\b(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS|CONNECT|TRACE)\b/g; - while ((match = methodPattern.exec(processedLine)) !== null) { - highlights.push({ - start: match.index, - end: match.index + match[0].length, - className: 'log-http-method', - priority: 6 - }); - } - - // 5. URL(仅匹配 HTTP(S) 完整URL,不匹配路径) - const urlPattern = /(https?:\/\/[^\s<>"]+)/g; - while ((match = urlPattern.exec(processedLine)) !== null) { - highlights.push({ - start: match.index, - end: match.index + match[0].length, - className: 'log-path', - priority: 4 - }); - } - - // 6. IP 地址 - const ipPattern = /\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b/g; - while ((match = ipPattern.exec(processedLine)) !== null) { - highlights.push({ - start: match.index, - end: match.index + match[0].length, - className: 'log-ip', - priority: 7 - }); - } - - // 7. 关键字 - const successPattern = /\b(success|successful|succeeded|completed|ok|done|passed)\b/gi; - while ((match = successPattern.exec(processedLine)) !== null) { - highlights.push({ - start: match.index, - end: match.index + match[0].length, - className: 'log-keyword-success', - priority: 3 - }); - } - - const errorPattern = /\b(failed|failure|error|exception|panic|fatal|critical|aborted|denied|refused|timeout|invalid)\b/gi; - while ((match = errorPattern.exec(processedLine)) !== null) { - highlights.push({ - start: match.index, - end: match.index + match[0].length, - className: 'log-keyword-error', - priority: 3 - }); - } - - const warnPattern = /\b(warning|warn|deprecated|slow|retry|retrying)\b/gi; - while ((match = warnPattern.exec(processedLine)) !== null) { - highlights.push({ - start: match.index, - end: match.index + match[0].length, - className: 'log-keyword-warn', - priority: 3 - }); - } - - // 8. 数字和单位 - const numberPattern = /\b(\d+(?:\.\d+)?)(ms|μs|ns|s|KB|MB|GB|TB|B|%)\b/g; - while ((match = numberPattern.exec(processedLine)) !== null) { - highlights.push({ - start: match.index, - end: match.index + match[0].length, - className: 'log-number-unit', - priority: 2 - }); - } - - // 移除重叠的高亮(保留优先级高的) - highlights.sort((a, b) => { - if (a.start !== b.start) return a.start - b.start; - return b.priority - a.priority; - }); - - const filteredHighlights = []; - for (const h of highlights) { - const overlaps = filteredHighlights.some(existing => - (h.start >= existing.start && h.start < existing.end) || - (h.end > existing.start && h.end <= existing.end) || - (h.start <= existing.start && h.end >= existing.end) - ); - if (!overlaps) { - filteredHighlights.push(h); - } - } - - // 构建最终的 HTML - filteredHighlights.sort((a, b) => a.start - b.start); - let result = ''; - let lastIndex = 0; - - for (const h of filteredHighlights) { - // 添加高亮之前的文本 - if (h.start > lastIndex) { - result += this.escapeHtml(processedLine.substring(lastIndex, h.start)); - } - // 添加高亮的文本 - result += `${this.escapeHtml(processedLine.substring(h.start, h.end))}`; - lastIndex = h.end; - } - - // 添加剩余的文本 - if (lastIndex < processedLine.length) { - result += this.escapeHtml(processedLine.substring(lastIndex)); - } - - return `${result}`; - }).join(''); - } - - // 检测 HTTP 状态码 - detectHttpStatus(line) { - if (!line) return null; - - // 更精确的 HTTP 状态码匹配模式 - // 匹配: - // 1. "| 状态码 |"(如:| 200 | 或 |200|) - // 2. "状态码 -" 或 "状态码-"(如:200 - 45ms 或 200-45ms) - // 3. "HTTP方法 路径 状态码"(如:GET /api 200) - // 4. "状态码 OK/Error/等状态文本"(如:200 OK) - // 5. "status: 状态码" 或 "code: 状态码" - // 排除: - // - 文件路径中的数字(如 .go:396) - // - 端口号(如 :8080) - // - 普通数字序列 - - const patterns = [ - // 匹配 "| 状态码 |" 或 "|状态码|"(日志中常见格式) - /\|\s*([1-5]\d{2})\s*\|/, - // 匹配 "状态码 -" 或 "状态码-" - /\b([1-5]\d{2})\s*-/, - // 匹配 HTTP 方法后的状态码(GET/POST/等 路径 状态码) - /\b(?:GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS|CONNECT|TRACE)\s+\S+\s+([1-5]\d{2})\b/, - // 匹配 "status:" 或 "code:" 后的状态码 - /\b(?:status|code|http)[:\s]+([1-5]\d{2})\b/i, - // 匹配状态码后跟状态文本(200 OK, 404 Not Found 等) - /\b([1-5]\d{2})\s+(?:OK|Created|Accepted|No Content|Moved|Found|Bad Request|Unauthorized|Forbidden|Not Found|Method Not Allowed|Internal Server Error|Bad Gateway|Service Unavailable|Gateway Timeout)\b/i - ]; - - for (const pattern of patterns) { - const match = line.match(pattern); - if (match) { - const code = parseInt(match[1], 10); - if (Number.isNaN(code)) { - continue; - } - - if (code >= 500) { - return { code, bucket: '5xx', match: match[1] }; - } - if (code >= 400) { - return { code, bucket: '4xx', match: match[1] }; - } - if (code >= 300) { - return { code, bucket: '3xx', match: match[1] }; - } - if (code >= 200) { - return { code, bucket: '2xx', match: match[1] }; - } - if (code >= 100) { - return { code, bucket: '1xx', match: match[1] }; - } - } - } - - return null; - } - - // 下载日志 - async downloadLogs() { - try { - const response = await this.makeRequest('/logs', { - method: 'GET' - }); - - if (response && response.lines && response.lines.length > 0) { - // 将数组转换为文本 - const logsText = response.lines.join('\n'); - const blob = new Blob([logsText], { type: 'text/plain' }); - const url = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `cli-proxy-api-logs-${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.log`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - window.URL.revokeObjectURL(url); - this.showNotification(i18n.t('logs.download_success'), 'success'); - } else { - this.showNotification(i18n.t('logs.empty_title'), 'info'); - } - } catch (error) { - console.error('下载日志失败:', error); - this.showNotification(`${i18n.t('notification.download_failed')}: ${error.message}`, 'error'); - } - } - - // 清空日志 - async clearLogs() { - if (!confirm(i18n.t('logs.clear_confirm'))) { - return; - } - - try { - const response = await this.makeRequest('/logs', { - method: 'DELETE' - }); - - // 根据返回的 removed 数量显示通知 - if (response && response.status === 'ok') { - const removedCount = response.removed || 0; - const message = `${i18n.t('logs.clear_success')} (${i18n.t('logs.removed')}: ${removedCount} ${i18n.t('logs.lines')})`; - this.showNotification(message, 'success'); - } else { - this.showNotification(i18n.t('logs.clear_success'), 'success'); - } - - // 重置时间戳 - this.latestLogTimestamp = null; - // 全量刷新 - await this.refreshLogs(false); - } catch (error) { - console.error('清空日志失败:', error); - this.showNotification(`${i18n.t('notification.delete_failed')}: ${error.message}`, 'error'); - } - } - - // 切换日志自动刷新 - toggleLogsAutoRefresh(enabled) { - if (enabled) { - // 启动自动刷新 - if (this.logsRefreshTimer) { - clearInterval(this.logsRefreshTimer); - } - this.logsRefreshTimer = setInterval(() => { - const logsSection = document.getElementById('logs'); - // 只在日志页面可见时刷新 - if (logsSection && logsSection.classList.contains('active')) { - // 使用增量加载 - this.refreshLogs(true); - } - }, 5000); // 每5秒刷新一次 - this.showNotification(i18n.t('logs.auto_refresh_enabled'), 'success'); - } else { - // 停止自动刷新 - if (this.logsRefreshTimer) { - clearInterval(this.logsRefreshTimer); - this.logsRefreshTimer = null; - } - this.showNotification(i18n.t('logs.auto_refresh_disabled'), 'info'); - } - } - // HTML转义工具函数 escapeHtml(text) { const div = document.createElement('div'); @@ -2421,307 +901,6 @@ class CLIProxyManager { return div.innerHTML; } - // 更新项目切换设置 - async updateSwitchProject(enabled) { - try { - await this.makeRequest('/quota-exceeded/switch-project', { - method: 'PUT', - body: JSON.stringify({ value: enabled }) - }); - this.clearCache(); // 清除缓存 - this.showNotification(i18n.t('notification.quota_switch_project_updated'), 'success'); - } catch (error) { - this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error'); - document.getElementById('switch-project-toggle').checked = !enabled; - } - } - - // 更新预览模型切换设置 - async updateSwitchPreviewModel(enabled) { - try { - await this.makeRequest('/quota-exceeded/switch-preview-model', { - method: 'PUT', - body: JSON.stringify({ value: enabled }) - }); - this.clearCache(); // 清除缓存 - this.showNotification(i18n.t('notification.quota_switch_preview_updated'), 'success'); - } catch (error) { - this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error'); - document.getElementById('switch-preview-model-toggle').checked = !enabled; - } - } - - - // 加载API密钥 - async loadApiKeys() { - try { - const config = await this.getConfig(); - if (config['api-keys']) { - this.renderApiKeys(config['api-keys']); - } - } catch (error) { - console.error('加载API密钥失败:', error); - } - } - - // 渲染API密钥列表 - renderApiKeys(keys) { - const container = document.getElementById('api-keys-list'); - - if (keys.length === 0) { - container.innerHTML = ` -
- -

${i18n.t('api_keys.empty_title')}

-

${i18n.t('api_keys.empty_desc')}

-
- `; - return; - } - - container.innerHTML = keys.map((key, index) => { - const normalizedKey = typeof key === 'string' ? key : String(key ?? ''); - const maskedDisplay = this.escapeHtml(this.maskApiKey(normalizedKey)); - const keyArgument = JSON.stringify(normalizedKey).replace(/"/g, '"'); - return ` -
-
-
${i18n.t('api_keys.item_title')} #${index + 1}
-
${maskedDisplay}
-
-
- - -
-
- `; - }).join(''); - } - - // 遮蔽API密钥显示 - maskApiKey(key) { - if (key === null || key === undefined) { - return ''; - } - const normalizedKey = typeof key === 'string' ? key : String(key); - if (normalizedKey.length > 8) { - return normalizedKey.substring(0, 4) + '...' + normalizedKey.substring(normalizedKey.length - 4); - } else if (normalizedKey.length > 4) { - return normalizedKey.substring(0, 2) + '...' + normalizedKey.substring(normalizedKey.length - 2); - } else if (normalizedKey.length > 2) { - return normalizedKey.substring(0, 1) + '...' + normalizedKey.substring(normalizedKey.length - 1); - } - return normalizedKey; - } - - // HTML 转义,防止 XSS - escapeHtml(value) { - if (value === null || value === undefined) return ''; - return String(value) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - } - - // 兼容服务端返回的数组结构 - normalizeArrayResponse(data, key) { - if (Array.isArray(data)) { - return data; - } - if (data && Array.isArray(data[key])) { - return data[key]; - } - if (data && Array.isArray(data.items)) { - return data.items; - } - return []; - } - - // 添加一行自定义请求头输入 - addHeaderField(wrapperId, header = {}) { - const wrapper = document.getElementById(wrapperId); - if (!wrapper) return; - - const row = document.createElement('div'); - row.className = 'header-input-row'; - const keyValue = typeof header.key === 'string' ? header.key : ''; - const valueValue = typeof header.value === 'string' ? header.value : ''; - row.innerHTML = ` -
- - : - - -
- `; - - const removeBtn = row.querySelector('.header-remove-btn'); - if (removeBtn) { - removeBtn.addEventListener('click', () => { - wrapper.removeChild(row); - if (wrapper.childElementCount === 0) { - this.addHeaderField(wrapperId); - } - }); - } - - wrapper.appendChild(row); - } - - // 填充自定义请求头输入 - populateHeaderFields(wrapperId, headers = null) { - const wrapper = document.getElementById(wrapperId); - if (!wrapper) return; - wrapper.innerHTML = ''; - - const entries = (headers && typeof headers === 'object') - ? Object.entries(headers).filter(([key, value]) => key && value !== undefined && value !== null) - : []; - - if (!entries.length) { - this.addHeaderField(wrapperId); - return; - } - - entries.forEach(([key, value]) => this.addHeaderField(wrapperId, { key, value: String(value ?? '') })); - } - - // 收集自定义请求头输入 - collectHeaderInputs(wrapperId) { - const wrapper = document.getElementById(wrapperId); - if (!wrapper) return null; - - const rows = Array.from(wrapper.querySelectorAll('.header-input-row')); - const headers = {}; - - rows.forEach(row => { - const keyInput = row.querySelector('.header-key-input'); - const valueInput = row.querySelector('.header-value-input'); - const key = keyInput ? keyInput.value.trim() : ''; - const value = valueInput ? valueInput.value.trim() : ''; - if (key && value) { - headers[key] = value; - } - }); - - return Object.keys(headers).length ? headers : null; - } - - addApiKeyEntryField(wrapperId, entry = {}) { - const wrapper = document.getElementById(wrapperId); - if (!wrapper) return; - - const row = document.createElement('div'); - row.className = 'api-key-input-row'; - const keyValue = typeof entry?.['api-key'] === 'string' ? entry['api-key'] : ''; - const proxyValue = typeof entry?.['proxy-url'] === 'string' ? entry['proxy-url'] : ''; - row.innerHTML = ` -
- - - -
- `; - - const removeBtn = row.querySelector('.api-key-remove-btn'); - if (removeBtn) { - removeBtn.addEventListener('click', () => { - wrapper.removeChild(row); - if (wrapper.childElementCount === 0) { - this.addApiKeyEntryField(wrapperId); - } - }); - } - - wrapper.appendChild(row); - } - - populateApiKeyEntryFields(wrapperId, entries = []) { - const wrapper = document.getElementById(wrapperId); - if (!wrapper) return; - wrapper.innerHTML = ''; - - if (!Array.isArray(entries) || entries.length === 0) { - this.addApiKeyEntryField(wrapperId); - return; - } - - entries.forEach(entry => this.addApiKeyEntryField(wrapperId, entry)); - } - - collectApiKeyEntryInputs(wrapperId) { - const wrapper = document.getElementById(wrapperId); - if (!wrapper) return []; - - const rows = Array.from(wrapper.querySelectorAll('.api-key-input-row')); - const entries = []; - - rows.forEach(row => { - const keyInput = row.querySelector('.api-key-value-input'); - const proxyInput = row.querySelector('.api-key-proxy-input'); - const key = keyInput ? keyInput.value.trim() : ''; - const proxy = proxyInput ? proxyInput.value.trim() : ''; - if (key) { - entries.push({ 'api-key': key, 'proxy-url': proxy }); - } - }); - - return entries; - } - - // 规范化并写入请求头 - applyHeadersToConfig(target, headers) { - if (!target) { - return; - } - if (headers && typeof headers === 'object' && Object.keys(headers).length) { - target.headers = { ...headers }; - } else { - delete target.headers; - } - } - - // 渲染请求头徽章 - renderHeaderBadges(headers) { - if (!headers || typeof headers !== 'object') { - return ''; - } - const entries = Object.entries(headers).filter(([key, value]) => key && value !== undefined && value !== null && value !== ''); - if (!entries.length) { - return ''; - } - - const badges = entries.map(([key, value]) => ` - ${this.escapeHtml(key)}: ${this.escapeHtml(String(value))} - `).join(''); - - return ` -
- ${i18n.t('common.custom_headers_label')}: -
- ${badges} -
-
- `; - } - - // 构造Codex配置,保持未展示的字段 - buildCodexConfig(apiKey, baseUrl, proxyUrl, original = {}, headers = null) { - const result = { - ...original, - 'api-key': apiKey, - 'base-url': baseUrl || '', - 'proxy-url': proxyUrl || '' - }; - this.applyHeadersToConfig(result, headers); - return result; - } // 显示添加API密钥模态框 showAddApiKeyModal() { @@ -2829,3739 +1008,13 @@ class CLIProxyManager { } } - // Load Gemini keys - async loadGeminiKeys() { - try { - const config = await this.getConfig(); - const keys = this.getGeminiKeysFromConfig(config); - await this.renderGeminiKeys(keys); - } catch (error) { - console.error('加载Gemini密钥失败:', error); - } - } - - // Extract Gemini keys from config with backward compatibility - getGeminiKeysFromConfig(config) { - if (!config) { - return []; - } - - const geminiKeys = Array.isArray(config['gemini-api-key']) ? config['gemini-api-key'] : []; - if (geminiKeys.length > 0) { - return geminiKeys; - } - - const legacyKeys = Array.isArray(config['generative-language-api-key']) ? config['generative-language-api-key'] : []; - return legacyKeys - .map(item => { - if (item && typeof item === 'object') { - return { ...item }; - } - if (typeof item === 'string') { - const trimmed = item.trim(); - if (trimmed) { - return { 'api-key': trimmed }; - } - } - return null; - }) - .filter(Boolean); - } - - // Render Gemini key list - async renderGeminiKeys(keys, keyStats = null) { - const container = document.getElementById('gemini-keys-list'); - if (!container) { - return; - } - const normalizedList = (Array.isArray(keys) ? keys : []).map(item => { - let normalized = null; - if (item && typeof item === 'object') { - normalized = { ...item }; - } else if (typeof item === 'string') { - const trimmed = item.trim(); - if (trimmed) { - normalized = { 'api-key': trimmed }; - } - } - - if (normalized && !normalized['base-url'] && normalized['base_url']) { - normalized['base-url'] = normalized['base_url']; - } - - return normalized; - }).filter(config => config && config['api-key']); - this.cachedGeminiKeys = normalizedList; - - if (normalizedList.length === 0) { - container.innerHTML = ` -
- -

${i18n.t('ai_providers.gemini_empty_title')}

-

${i18n.t('ai_providers.gemini_empty_desc')}

-
- `; - return; - } - - // 使用传入的keyStats,如果没有则获取一次 - if (!keyStats) { - keyStats = await this.getKeyStats(); - } - const stats = keyStats; - - container.innerHTML = normalizedList.map((config, index) => { - const rawKey = config['api-key'] || ''; - const masked = this.maskApiKey(rawKey || ''); - const maskedDisplay = this.escapeHtml(masked); - const keyStats = (rawKey && (stats[rawKey] || stats[masked])) || { success: 0, failure: 0 }; - const configJson = JSON.stringify(config).replace(/"/g, '"'); - const apiKeyJson = JSON.stringify(rawKey || '').replace(/"/g, '"'); - const baseUrl = config['base-url'] || config['base_url'] || ''; - return ` -
-
-
${i18n.t('ai_providers.gemini_item_title')} #${index + 1}
-
${maskedDisplay}
- ${baseUrl ? `
${i18n.t('common.base_url')}: ${this.escapeHtml(baseUrl)}
` : ''} - ${this.renderHeaderBadges(config.headers)} -
- - ${i18n.t('stats.success')}: ${keyStats.success} - - - ${i18n.t('stats.failure')}: ${keyStats.failure} - -
-
-
- - -
-
- `}).join(''); - } - - // 显示添加Gemini密钥模态框 - showAddGeminiKeyModal() { - const modal = document.getElementById('modal'); - const modalBody = document.getElementById('modal-body'); - - modalBody.innerHTML = ` -

${i18n.t('ai_providers.gemini_add_modal_title')}

-
- -

${i18n.t('ai_providers.gemini_add_modal_key_hint')}

-
- -
-
- -

${i18n.t('common.custom_headers_hint')}

-
- -
- - `; - - modal.style.display = 'block'; - this.populateGeminiKeyFields('new-gemini-keys-wrapper'); - this.populateHeaderFields('new-gemini-headers-wrapper'); - } - - // 添加Gemini密钥 - async addGeminiKey() { - const entries = this.collectGeminiKeyFieldInputs('new-gemini-keys-wrapper'); - const headers = this.collectHeaderInputs('new-gemini-headers-wrapper'); - - if (!entries.length) { - this.showNotification(i18n.t('notification.gemini_multi_input_required'), 'error'); - return; - } - - try { - const data = await this.makeRequest('/gemini-api-key'); - let currentKeys = Array.isArray(data['gemini-api-key']) ? data['gemini-api-key'] : []; - const existingKeys = new Set(currentKeys.map(item => item && item['api-key']).filter(Boolean)); - const batchSeen = new Set(); - - let successCount = 0; - let skippedCount = 0; - let failedCount = 0; - - for (const entry of entries) { - const apiKey = entry['api-key']; - if (!apiKey) { - continue; - } - - if (batchSeen.has(apiKey)) { - skippedCount++; - continue; - } - batchSeen.add(apiKey); - - if (existingKeys.has(apiKey)) { - skippedCount++; - continue; - } - - const newConfig = { 'api-key': apiKey }; - const baseUrl = entry['base-url']; - if (baseUrl) { - newConfig['base-url'] = baseUrl; - } else { - delete newConfig['base-url']; - } - this.applyHeadersToConfig(newConfig, headers); - - const nextKeys = [...currentKeys, newConfig]; - - try { - await this.makeRequest('/gemini-api-key', { - method: 'PUT', - body: JSON.stringify(nextKeys) - }); - currentKeys = nextKeys; - existingKeys.add(apiKey); - successCount++; - } catch (error) { - failedCount++; - console.error('Gemini key add failed:', error); - } - } - - this.clearCache(); // 清除缓存 - this.closeModal(); - this.loadGeminiKeys(); - - if (successCount === 1 && skippedCount === 0 && failedCount === 0) { - this.showNotification(i18n.t('notification.gemini_key_added'), 'success'); - return; - } - - const summaryTemplate = i18n.t('notification.gemini_multi_summary'); - const summary = summaryTemplate - .replace('{success}', successCount) - .replace('{skipped}', skippedCount) - .replace('{failed}', failedCount); - const status = failedCount > 0 ? 'warning' : (successCount > 0 ? 'success' : 'info'); - this.showNotification(summary, status); - } catch (error) { - this.showNotification(`${i18n.t('notification.gemini_multi_failed')}: ${error.message}`, 'error'); - } - } - - addGeminiKeyField(wrapperId, entry = {}, options = {}) { - const wrapper = document.getElementById(wrapperId); - if (!wrapper) return; - - const row = document.createElement('div'); - row.className = 'api-key-input-row'; - const apiKeyValue = typeof entry?.['api-key'] === 'string' ? entry['api-key'] : ''; - const baseUrlValue = typeof entry?.['base-url'] === 'string' - ? entry['base-url'] - : (typeof entry?.['base_url'] === 'string' ? entry['base_url'] : ''); - const allowRemoval = options.allowRemoval !== false; - const removeButtonHtml = allowRemoval - ? `` - : ''; - row.innerHTML = ` -
- - - ${removeButtonHtml} -
- `; - - if (allowRemoval) { - const removeBtn = row.querySelector('.gemini-key-remove-btn'); - if (removeBtn) { - removeBtn.addEventListener('click', () => { - wrapper.removeChild(row); - if (wrapper.childElementCount === 0) { - this.addGeminiKeyField(wrapperId, {}, options); - } - }); - } - } - - wrapper.appendChild(row); - } - - populateGeminiKeyFields(wrapperId, entries = [], options = {}) { - const wrapper = document.getElementById(wrapperId); - if (!wrapper) return; - wrapper.innerHTML = ''; - - if (!Array.isArray(entries) || entries.length === 0) { - this.addGeminiKeyField(wrapperId, {}, options); - return; - } - - entries.forEach(entry => this.addGeminiKeyField(wrapperId, entry, options)); - } - - collectGeminiKeyFieldInputs(wrapperId) { - const wrapper = document.getElementById(wrapperId); - if (!wrapper) return []; - - const rows = Array.from(wrapper.querySelectorAll('.api-key-input-row')); - const entries = []; - - rows.forEach(row => { - const keyInput = row.querySelector('.api-key-value-input'); - const urlInput = row.querySelector('.api-key-proxy-input'); - const apiKey = keyInput ? keyInput.value.trim() : ''; - const baseUrl = urlInput ? urlInput.value.trim() : ''; - if (apiKey) { - entries.push({ 'api-key': apiKey, 'base-url': baseUrl }); - } - }); - - return entries; - } - - // 编辑Gemini密钥 - editGeminiKey(index, config) { - const modal = document.getElementById('modal'); - const modalBody = document.getElementById('modal-body'); - this.currentGeminiEditConfig = config || {}; - - modalBody.innerHTML = ` -

${i18n.t('ai_providers.gemini_edit_modal_title')}

-
- -
-
-
- -

${i18n.t('common.custom_headers_hint')}

-
- -
- - `; - - modal.style.display = 'block'; - this.populateGeminiKeyFields('edit-gemini-keys-wrapper', [config], { allowRemoval: false }); - this.populateHeaderFields('edit-gemini-headers-wrapper', config.headers || null); - } - - // 更新Gemini密钥 - async updateGeminiKey(index) { - const entries = this.collectGeminiKeyFieldInputs('edit-gemini-keys-wrapper'); - if (!entries.length) { - this.showNotification(i18n.t('notification.please_enter') + ' ' + i18n.t('notification.gemini_api_key'), 'error'); - return; - } - const entry = entries[0]; - const newKey = entry['api-key']; - const baseUrl = entry['base-url'] || ''; - const headers = this.collectHeaderInputs('edit-gemini-headers-wrapper'); - - if (!newKey) { - this.showNotification(i18n.t('notification.please_enter') + ' ' + i18n.t('notification.gemini_api_key'), 'error'); - return; - } - - try { - const existingConfig = (this.cachedGeminiKeys && this.cachedGeminiKeys[index]) || this.currentGeminiEditConfig || {}; - const newConfig = { ...existingConfig, 'api-key': newKey }; - if (baseUrl) { - newConfig['base-url'] = baseUrl; - } else { - delete newConfig['base-url']; - } - this.applyHeadersToConfig(newConfig, headers); - - await this.makeRequest('/gemini-api-key', { - method: 'PATCH', - body: JSON.stringify({ index, value: newConfig }) - }); - - this.clearCache(); // 清除缓存 - this.closeModal(); - this.loadGeminiKeys(); - this.currentGeminiEditConfig = null; - this.showNotification(i18n.t('notification.gemini_key_updated'), 'success'); - } catch (error) { - this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error'); - } - } - - // 删除Gemini密钥 - async deleteGeminiKey(apiKey) { - if (!confirm(i18n.t('ai_providers.gemini_delete_confirm'))) return; - - try { - await this.makeRequest(`/gemini-api-key?api-key=${encodeURIComponent(apiKey)}`, { method: 'DELETE' }); - this.clearCache(); // 清除缓存 - this.loadGeminiKeys(); - this.showNotification(i18n.t('notification.gemini_key_deleted'), 'success'); - } catch (error) { - this.showNotification(`${i18n.t('notification.delete_failed')}: ${error.message}`, 'error'); - } - } - - // 加载Codex密钥 - async loadCodexKeys() { - try { - const config = await this.getConfig(); - const keys = Array.isArray(config['codex-api-key']) ? config['codex-api-key'] : []; - await this.renderCodexKeys(keys); - } catch (error) { - console.error('加载Codex密钥失败:', error); - } - } - - // 渲染Codex密钥列表 - async renderCodexKeys(keys, keyStats = null) { - const container = document.getElementById('codex-keys-list'); - if (!container) { - return; - } - const list = Array.isArray(keys) ? keys : []; - - if (list.length === 0) { - container.innerHTML = ` -
- -

${i18n.t('ai_providers.codex_empty_title')}

-

${i18n.t('ai_providers.codex_empty_desc')}

-
- `; - return; - } - - // 使用传入的keyStats,如果没有则获取一次 - if (!keyStats) { - keyStats = await this.getKeyStats(); - } - const stats = keyStats; - - container.innerHTML = list.map((config, index) => { - const rawKey = config['api-key'] || ''; - const masked = this.maskApiKey(rawKey || ''); - const maskedDisplay = this.escapeHtml(masked); - const keyStats = (rawKey && (stats[rawKey] || stats[masked])) || { success: 0, failure: 0 }; - const deleteArg = JSON.stringify(rawKey).replace(/"/g, '"'); - return ` -
-
-
${i18n.t('ai_providers.codex_item_title')} #${index + 1}
-
${i18n.t('common.api_key')}: ${maskedDisplay}
- ${config['base-url'] ? `
${i18n.t('common.base_url')}: ${this.escapeHtml(config['base-url'])}
` : ''} - ${config['proxy-url'] ? `
${i18n.t('common.proxy_url')}: ${this.escapeHtml(config['proxy-url'])}
` : ''} - ${this.renderHeaderBadges(config.headers)} -
- - ${i18n.t('stats.success')}: ${keyStats.success} - - - ${i18n.t('stats.failure')}: ${keyStats.failure} - -
-
-
- - -
-
- `}).join(''); - } - - // 显示添加Codex密钥模态框 - showAddCodexKeyModal() { - const modal = document.getElementById('modal'); - const modalBody = document.getElementById('modal-body'); - - modalBody.innerHTML = ` -

${i18n.t('ai_providers.codex_add_modal_title')}

-
- - -
-
- - -
-
- - -
-
- -

${i18n.t('common.custom_headers_hint')}

-
- -
- - `; - - modal.style.display = 'block'; - this.populateHeaderFields('new-codex-headers-wrapper'); - } - - // 添加Codex密钥 - async addCodexKey() { - const apiKey = document.getElementById('new-codex-key').value.trim(); - const baseUrl = document.getElementById('new-codex-url').value.trim(); - const proxyUrl = document.getElementById('new-codex-proxy').value.trim(); - const headers = this.collectHeaderInputs('new-codex-headers-wrapper'); - - if (!apiKey) { - this.showNotification(i18n.t('notification.field_required'), 'error'); - return; - } - if (!baseUrl) { - this.showNotification(i18n.t('notification.codex_base_url_required'), 'error'); - return; - } - - try { - const data = await this.makeRequest('/codex-api-key'); - const currentKeys = this.normalizeArrayResponse(data, 'codex-api-key').map(item => ({ ...item })); - - const newConfig = this.buildCodexConfig(apiKey, baseUrl, proxyUrl, {}, headers); - - currentKeys.push(newConfig); - - await this.makeRequest('/codex-api-key', { - method: 'PUT', - body: JSON.stringify(currentKeys) - }); - - this.clearCache(); // 清除缓存 - this.closeModal(); - this.loadCodexKeys(); - this.showNotification(i18n.t('notification.codex_config_added'), 'success'); - } catch (error) { - this.showNotification(`${i18n.t('notification.add_failed')}: ${error.message}`, 'error'); - } - } - - // 编辑Codex密钥 - editCodexKey(index, config) { - const modal = document.getElementById('modal'); - const modalBody = document.getElementById('modal-body'); - - modalBody.innerHTML = ` -

${i18n.t('ai_providers.codex_edit_modal_title')}

-
- - -
-
- - -
-
- - -
-
- -

${i18n.t('common.custom_headers_hint')}

-
- -
- - `; - - modal.style.display = 'block'; - this.populateHeaderFields('edit-codex-headers-wrapper', config.headers || null); - } - - // 更新Codex密钥 - async updateCodexKey(index) { - const apiKey = document.getElementById('edit-codex-key').value.trim(); - const baseUrl = document.getElementById('edit-codex-url').value.trim(); - const proxyUrl = document.getElementById('edit-codex-proxy').value.trim(); - const headers = this.collectHeaderInputs('edit-codex-headers-wrapper'); - - if (!apiKey) { - this.showNotification(i18n.t('notification.field_required'), 'error'); - return; - } - if (!baseUrl) { - this.showNotification(i18n.t('notification.codex_base_url_required'), 'error'); - return; - } - - try { - const listResponse = await this.makeRequest('/codex-api-key'); - const currentList = this.normalizeArrayResponse(listResponse, 'codex-api-key'); - - if (!Array.isArray(currentList) || index < 0 || index >= currentList.length) { - throw new Error('Invalid codex configuration index'); - } - - const original = currentList[index] ? { ...currentList[index] } : {}; - const newConfig = this.buildCodexConfig(apiKey, baseUrl, proxyUrl, original, headers); - - await this.makeRequest('/codex-api-key', { - method: 'PATCH', - body: JSON.stringify({ index, value: newConfig }) - }); - - this.clearCache(); // 清除缓存 - this.closeModal(); - this.loadCodexKeys(); - this.showNotification(i18n.t('notification.codex_config_updated'), 'success'); - } catch (error) { - this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error'); - } - } - - // 删除Codex密钥 - async deleteCodexKey(apiKey) { - if (!confirm(i18n.t('ai_providers.codex_delete_confirm'))) return; - - try { - await this.makeRequest(`/codex-api-key?api-key=${encodeURIComponent(apiKey)}`, { method: 'DELETE' }); - this.clearCache(); // 清除缓存 - this.loadCodexKeys(); - this.showNotification(i18n.t('notification.codex_config_deleted'), 'success'); - } catch (error) { - this.showNotification(`${i18n.t('notification.delete_failed')}: ${error.message}`, 'error'); - } - } - - // 加载Claude密钥 - async loadClaudeKeys() { - try { - const config = await this.getConfig(); - const keys = Array.isArray(config['claude-api-key']) ? config['claude-api-key'] : []; - await this.renderClaudeKeys(keys); - } catch (error) { - console.error('加载Claude密钥失败:', error); - } - } - - // 渲染Claude密钥列表 - async renderClaudeKeys(keys, keyStats = null) { - const container = document.getElementById('claude-keys-list'); - if (!container) { - return; - } - const list = Array.isArray(keys) ? keys : []; - - if (list.length === 0) { - container.innerHTML = ` -
- -

${i18n.t('ai_providers.claude_empty_title')}

-

${i18n.t('ai_providers.claude_empty_desc')}

-
- `; - return; - } - - // 使用传入的keyStats,如果没有则获取一次 - if (!keyStats) { - keyStats = await this.getKeyStats(); - } - const stats = keyStats; - - container.innerHTML = list.map((config, index) => { - const rawKey = config['api-key'] || ''; - const masked = this.maskApiKey(rawKey || ''); - const maskedDisplay = this.escapeHtml(masked); - const keyStats = (rawKey && (stats[rawKey] || stats[masked])) || { success: 0, failure: 0 }; - const deleteArg = JSON.stringify(rawKey).replace(/"/g, '"'); - const models = Array.isArray(config.models) ? config.models : []; - const modelsCountHtml = models.length - ? `
${i18n.t('ai_providers.claude_models_count')}: ${models.length}
` - : ''; - const modelsBadgesHtml = this.renderModelBadges(models); - return ` -
-
-
${i18n.t('ai_providers.claude_item_title')} #${index + 1}
-
${i18n.t('common.api_key')}: ${maskedDisplay}
- ${config['base-url'] ? `
${i18n.t('common.base_url')}: ${this.escapeHtml(config['base-url'])}
` : ''} - ${config['proxy-url'] ? `
${i18n.t('common.proxy_url')}: ${this.escapeHtml(config['proxy-url'])}
` : ''} - ${this.renderHeaderBadges(config.headers)} - ${modelsCountHtml} - ${modelsBadgesHtml} -
- - ${i18n.t('stats.success')}: ${keyStats.success} - - - ${i18n.t('stats.failure')}: ${keyStats.failure} - -
-
-
- - -
-
- `}).join(''); - } - - // 显示添加Claude密钥模态框 - showAddClaudeKeyModal() { - const modal = document.getElementById('modal'); - const modalBody = document.getElementById('modal-body'); - - modalBody.innerHTML = ` -

${i18n.t('ai_providers.claude_add_modal_title')}

-
- - -
-
- - -
-
- - -
-
- -

${i18n.t('common.custom_headers_hint')}

-
- -
-
- -

${i18n.t('ai_providers.claude_models_hint')}

-
- -
- - `; - - modal.style.display = 'block'; - this.populateHeaderFields('new-claude-headers-wrapper'); - this.populateModelFields('new-claude-models-wrapper'); - } - - // 添加Claude密钥 - async addClaudeKey() { - const apiKey = document.getElementById('new-claude-key').value.trim(); - const baseUrl = document.getElementById('new-claude-url').value.trim(); - const proxyUrl = document.getElementById('new-claude-proxy').value.trim(); - const headers = this.collectHeaderInputs('new-claude-headers-wrapper'); - const models = this.collectModelInputs('new-claude-models-wrapper'); - - if (!apiKey) { - this.showNotification(i18n.t('notification.field_required'), 'error'); - return; - } - - try { - const data = await this.makeRequest('/claude-api-key'); - const currentKeys = data['claude-api-key'] || []; - - const newConfig = { 'api-key': apiKey }; - if (baseUrl) { - newConfig['base-url'] = baseUrl; - } - if (proxyUrl) { - newConfig['proxy-url'] = proxyUrl; - } - this.applyHeadersToConfig(newConfig, headers); - if (models.length) { - newConfig.models = models; - } - - currentKeys.push(newConfig); - - await this.makeRequest('/claude-api-key', { - method: 'PUT', - body: JSON.stringify(currentKeys) - }); - - this.clearCache(); // 清除缓存 - this.closeModal(); - this.loadClaudeKeys(); - this.showNotification(i18n.t('notification.claude_config_added'), 'success'); - } catch (error) { - this.showNotification(`${i18n.t('notification.add_failed')}: ${error.message}`, 'error'); - } - } - - // 编辑Claude密钥 - editClaudeKey(index, config) { - const modal = document.getElementById('modal'); - const modalBody = document.getElementById('modal-body'); - - modalBody.innerHTML = ` -

${i18n.t('ai_providers.claude_edit_modal_title')}

-
- - -
-
- - -
-
- - -
-
- -

${i18n.t('common.custom_headers_hint')}

-
- -
-
- -

${i18n.t('ai_providers.claude_models_hint')}

-
- -
- - `; - - modal.style.display = 'block'; - this.populateHeaderFields('edit-claude-headers-wrapper', config.headers || null); - this.populateModelFields('edit-claude-models-wrapper', Array.isArray(config.models) ? config.models : []); - } - - // 更新Claude密钥 - async updateClaudeKey(index) { - const apiKey = document.getElementById('edit-claude-key').value.trim(); - const baseUrl = document.getElementById('edit-claude-url').value.trim(); - const proxyUrl = document.getElementById('edit-claude-proxy').value.trim(); - const headers = this.collectHeaderInputs('edit-claude-headers-wrapper'); - const models = this.collectModelInputs('edit-claude-models-wrapper'); - - if (!apiKey) { - this.showNotification(i18n.t('notification.field_required'), 'error'); - return; - } - - try { - const newConfig = { 'api-key': apiKey }; - if (baseUrl) { - newConfig['base-url'] = baseUrl; - } - if (proxyUrl) { - newConfig['proxy-url'] = proxyUrl; - } - this.applyHeadersToConfig(newConfig, headers); - if (models.length) { - newConfig.models = models; - } - - await this.makeRequest('/claude-api-key', { - method: 'PATCH', - body: JSON.stringify({ index, value: newConfig }) - }); - - this.clearCache(); // 清除缓存 - this.closeModal(); - this.loadClaudeKeys(); - this.showNotification(i18n.t('notification.claude_config_updated'), 'success'); - } catch (error) { - this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error'); - } - } - - // 删除Claude密钥 - async deleteClaudeKey(apiKey) { - if (!confirm(i18n.t('ai_providers.claude_delete_confirm'))) return; - - try { - await this.makeRequest(`/claude-api-key?api-key=${encodeURIComponent(apiKey)}`, { method: 'DELETE' }); - this.clearCache(); // 清除缓存 - this.loadClaudeKeys(); - this.showNotification(i18n.t('notification.claude_config_deleted'), 'success'); - } catch (error) { - this.showNotification(`${i18n.t('notification.delete_failed')}: ${error.message}`, 'error'); - } - } - - // 加载OpenAI提供商 - async loadOpenAIProviders() { - try { - const config = await this.getConfig(); - const providers = Array.isArray(config['openai-compatibility']) ? config['openai-compatibility'] : []; - await this.renderOpenAIProviders(providers); - } catch (error) { - console.error('加载OpenAI提供商失败:', error); - } - } - - // 渲染OpenAI提供商列表 - async renderOpenAIProviders(providers, keyStats = null) { - const container = document.getElementById('openai-providers-list'); - if (!container) { - return; - } - const list = Array.isArray(providers) ? providers : []; - - if (list.length === 0) { - container.innerHTML = ` -
- -

${i18n.t('ai_providers.openai_empty_title')}

-

${i18n.t('ai_providers.openai_empty_desc')}

-
- `; - // 重置样式 - container.style.maxHeight = ''; - container.style.overflowY = ''; - return; - } - - // 根据提供商数量设置滚动条 - if (list.length > 5) { - container.style.maxHeight = '400px'; - container.style.overflowY = 'auto'; - } else { - container.style.maxHeight = ''; - container.style.overflowY = ''; - } - - // 使用传入的keyStats,如果没有则获取一次 - if (!keyStats) { - keyStats = await this.getKeyStats(); - } - const stats = keyStats; - - container.innerHTML = list.map((provider, index) => { - 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 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; - }); - - const deleteArg = JSON.stringify(name).replace(/"/g, '"'); - return ` -
-
-
${this.escapeHtml(name)}
-
${i18n.t('common.base_url')}: ${this.escapeHtml(baseUrl)}
- ${this.renderHeaderBadges(item.headers)} -
${i18n.t('ai_providers.openai_keys_count')}: ${apiKeyEntries.length}
-
${i18n.t('ai_providers.openai_models_count')}: ${models.length}
- ${this.renderModelBadges(models)} -
- - ${i18n.t('stats.success')}: ${totalSuccess} - - - ${i18n.t('stats.failure')}: ${totalFailure} - -
-
-
- - -
-
- `;}).join(''); - } - - // 显示添加OpenAI提供商模态框 - showAddOpenAIProviderModal() { - const modal = document.getElementById('modal'); - const modalBody = document.getElementById('modal-body'); - - modalBody.innerHTML = ` -

${i18n.t('ai_providers.openai_add_modal_title')}

-
- - -
-
- - -
-
- -

${i18n.t('ai_providers.openai_keys_hint')}

-
- -
-
- -

${i18n.t('common.custom_headers_hint')}

-
- -
-
- -

${i18n.t('ai_providers.openai_models_hint')}

-
- -
- - `; - - modal.style.display = 'block'; - this.populateModelFields('new-provider-models-wrapper', []); - this.populateHeaderFields('new-openai-headers-wrapper'); - this.populateApiKeyEntryFields('new-openai-keys-wrapper'); - } - - // 添加OpenAI提供商 - async addOpenAIProvider() { - const name = document.getElementById('new-provider-name').value.trim(); - const baseUrl = document.getElementById('new-provider-url').value.trim(); - const apiKeyEntries = this.collectApiKeyEntryInputs('new-openai-keys-wrapper'); - const models = this.collectModelInputs('new-provider-models-wrapper'); - const headers = this.collectHeaderInputs('new-openai-headers-wrapper'); - - if (!this.validateOpenAIProviderInput(name, baseUrl, models)) { - return; - } - - try { - const data = await this.makeRequest('/openai-compatibility'); - const currentProviders = data['openai-compatibility'] || []; - - const newProvider = { - name, - 'base-url': baseUrl, - 'api-key-entries': apiKeyEntries, - models - }; - this.applyHeadersToConfig(newProvider, headers); - - currentProviders.push(newProvider); - - await this.makeRequest('/openai-compatibility', { - method: 'PUT', - body: JSON.stringify(currentProviders) - }); - - this.clearCache(); // 清除缓存 - this.closeModal(); - this.loadOpenAIProviders(); - this.showNotification(i18n.t('notification.openai_provider_added'), 'success'); - } catch (error) { - this.showNotification(`${i18n.t('notification.add_failed')}: ${error.message}`, 'error'); - } - } - - // 编辑OpenAI提供商 - editOpenAIProvider(index, provider) { - const modal = document.getElementById('modal'); - const modalBody = document.getElementById('modal-body'); - - // 处理两种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 models = Array.isArray(provider?.models) ? provider.models : []; - - modalBody.innerHTML = ` -

${i18n.t('ai_providers.openai_edit_modal_title')}

-
- - -
-
- - -
-
- -

${i18n.t('ai_providers.openai_keys_hint')}

-
- -
-
- -

${i18n.t('common.custom_headers_hint')}

-
- -
-
- -

${i18n.t('ai_providers.openai_models_hint')}

-
- -
- - `; - - modal.style.display = 'block'; - this.populateModelFields('edit-provider-models-wrapper', models); - this.populateHeaderFields('edit-openai-headers-wrapper', provider?.headers || null); - this.populateApiKeyEntryFields('edit-openai-keys-wrapper', apiKeyEntries); - } - - // 更新OpenAI提供商 - async updateOpenAIProvider(index) { - const name = document.getElementById('edit-provider-name').value.trim(); - const baseUrl = document.getElementById('edit-provider-url').value.trim(); - const apiKeyEntries = this.collectApiKeyEntryInputs('edit-openai-keys-wrapper'); - const models = this.collectModelInputs('edit-provider-models-wrapper'); - const headers = this.collectHeaderInputs('edit-openai-headers-wrapper'); - - if (!this.validateOpenAIProviderInput(name, baseUrl, models)) { - return; - } - - try { - const updatedProvider = { - name, - 'base-url': baseUrl, - 'api-key-entries': apiKeyEntries, - models - }; - this.applyHeadersToConfig(updatedProvider, headers); - - await this.makeRequest('/openai-compatibility', { - method: 'PATCH', - body: JSON.stringify({ index, value: updatedProvider }) - }); - - this.clearCache(); // 清除缓存 - this.closeModal(); - this.loadOpenAIProviders(); - this.showNotification(i18n.t('notification.openai_provider_updated'), 'success'); - } catch (error) { - this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error'); - } - } - - // 删除OpenAI提供商 - async deleteOpenAIProvider(name) { - if (!confirm(i18n.t('ai_providers.openai_delete_confirm'))) return; - - try { - await this.makeRequest(`/openai-compatibility?name=${encodeURIComponent(name)}`, { method: 'DELETE' }); - this.clearCache(); // 清除缓存 - this.loadOpenAIProviders(); - this.showNotification(i18n.t('notification.openai_provider_deleted'), 'success'); - } catch (error) { - this.showNotification(`${i18n.t('notification.delete_failed')}: ${error.message}`, 'error'); - } - } - - // 加载认证文件 - async loadAuthFiles(keyStats = null) { - try { - const data = await this.makeRequest('/auth-files'); - // 如果没有传入keyStats,则获取一次 - if (!keyStats) { - keyStats = await this.getKeyStats(); - } - await this.renderAuthFiles(data.files || [], keyStats); - } catch (error) { - console.error('加载认证文件失败:', error); - } - } - - // 渲染认证文件列表 - async renderAuthFiles(files, keyStats = null) { - const container = document.getElementById('auth-files-list'); - if (!container) { - return; - } - - const allFiles = Array.isArray(files) ? files : []; - const visibleFiles = allFiles.filter(file => { - if (!file) return false; - return this.shouldDisplayDisabledGeminiCli(file) || file.disabled !== true; - }); - const stats = keyStats || await this.getKeyStats(); - - this.cachedAuthFiles = visibleFiles.map(file => ({ ...file })); - this.authFileStatsCache = stats || {}; - this.syncAuthFileControls(); - - if (this.cachedAuthFiles.length === 0) { - container.innerHTML = ` -
- -

${i18n.t('auth_files.empty_title')}

-

${i18n.t('auth_files.empty_desc')}

-
- `; - this.updateFilterButtons(new Set(['all'])); - this.bindAuthFileFilterEvents(); - this.applyAuthFileFilterState(false); - this.authFilesPagination.currentPage = 1; - this.authFilesPagination.totalPages = 1; - this.updatePaginationControls(0); - return; - } - - const existingTypes = new Set(['all']); - this.cachedAuthFiles.forEach(file => { - if (file.type) { - existingTypes.add(file.type); - } - }); - - this.updateFilterButtons(existingTypes); - this.bindAuthFileFilterEvents(); - this.applyAuthFileFilterState(false); - this.renderAuthFilesPage(this.authFilesPagination.currentPage); - this.bindAuthFileActionEvents(); - } - - isRuntimeOnlyAuthFile(file) { - if (!file) return false; - const runtimeValue = file.runtime_only; - return runtimeValue === true || runtimeValue === 'true'; - } - - shouldDisplayDisabledGeminiCli(file) { - if (!file) return false; - const provider = typeof file.provider === 'string' ? file.provider.toLowerCase() : ''; - const type = typeof file.type === 'string' ? file.type.toLowerCase() : ''; - const isGeminiCli = provider === 'gemini-cli' || type === 'gemini-cli'; - return isGeminiCli && !this.isRuntimeOnlyAuthFile(file); - } - - resolveAuthFileStats(file, stats = {}) { - const rawFileName = typeof file?.name === 'string' ? file.name : ''; - const defaultStats = { success: 0, failure: 0 }; - if (!rawFileName) { - return defaultStats; - } - - const fromName = stats[rawFileName]; - if (fromName && (fromName.success > 0 || fromName.failure > 0)) { - return fromName; - } - - let fileStats = fromName || defaultStats; - if (fileStats.success === 0 && fileStats.failure === 0) { - const nameWithoutExt = rawFileName.replace(/\.[^/.]+$/, ""); - - if (nameWithoutExt && nameWithoutExt !== rawFileName) { - const candidateNames = new Set([nameWithoutExt]); - const normalizedName = nameWithoutExt.toLowerCase(); - const typePrefix = typeof file?.type === 'string' ? file.type.trim().toLowerCase() : ''; - const providerPrefix = typeof file?.provider === 'string' ? file.provider.trim().toLowerCase() : ''; - const prefixList = []; - - if (typePrefix) { - prefixList.push(`${typePrefix}-`); - } - if (providerPrefix && providerPrefix !== typePrefix) { - prefixList.push(`${providerPrefix}-`); - } - - prefixList.forEach(prefix => { - if (prefix && normalizedName.startsWith(prefix)) { - const trimmed = nameWithoutExt.substring(prefix.length); - if (trimmed) { - candidateNames.add(trimmed); - } - } - }); - - for (const candidate of candidateNames) { - const statsByName = stats[candidate]; - if (statsByName && (statsByName.success > 0 || statsByName.failure > 0)) { - return statsByName; - } - - const maskedCandidate = this.maskApiKey(candidate); - if (maskedCandidate && maskedCandidate !== candidate) { - const statsByMaskedName = stats[maskedCandidate]; - if (statsByMaskedName && (statsByMaskedName.success > 0 || statsByMaskedName.failure > 0)) { - return statsByMaskedName; - } - } - } - } - - const possibleSources = []; - - const match = nameWithoutExt.match(/^([^@]+@[^-]+)-(.+)$/); - if (match) { - const email = match[1]; - const projectName = match[2]; - const fullDescription = `${email} (${projectName})`; - possibleSources.push(this.maskApiKey(fullDescription)); - } - - const typeMatch = nameWithoutExt.match(/^[^-]+-(.+)$/); - if (typeMatch) { - possibleSources.push(this.maskApiKey(typeMatch[1])); - } - - if (nameWithoutExt.startsWith('aistudio-')) { - possibleSources.push(this.maskApiKey(nameWithoutExt)); - } - - for (const source of possibleSources) { - if (stats[source] && (stats[source].success > 0 || stats[source].failure > 0)) { - return stats[source]; - } - } - } - - return fileStats; - } - - buildAuthFileItemHtml(file) { - const rawFileName = typeof file?.name === 'string' ? file.name : ''; - const safeFileName = this.escapeHtml(rawFileName); - const stats = this.authFileStatsCache || {}; - const fileStats = this.resolveAuthFileStats(file, stats); - const fileType = file.type || 'unknown'; - let typeDisplayKey; - switch (fileType) { - case 'qwen': - typeDisplayKey = 'auth_files.type_qwen'; - break; - case 'gemini': - typeDisplayKey = 'auth_files.type_gemini'; - break; - case 'gemini-cli': - typeDisplayKey = 'auth_files.type_gemini-cli'; - break; - case 'aistudio': - typeDisplayKey = 'auth_files.type_aistudio'; - break; - case 'claude': - typeDisplayKey = 'auth_files.type_claude'; - break; - case 'codex': - typeDisplayKey = 'auth_files.type_codex'; - break; - case 'iflow': - typeDisplayKey = 'auth_files.type_iflow'; - break; - case 'vertex': - typeDisplayKey = 'auth_files.type_vertex'; - break; - case 'empty': - typeDisplayKey = 'auth_files.type_empty'; - break; - default: - typeDisplayKey = 'auth_files.type_unknown'; - break; - } - const typeBadge = `${i18n.t(typeDisplayKey)}`; - const isRuntimeOnly = this.isRuntimeOnlyAuthFile(file); - const shouldShowMainFlag = this.shouldDisplayDisabledGeminiCli(file); - const mainFlagButton = shouldShowMainFlag ? ` - ` : ''; - const actionsHtml = isRuntimeOnly ? ` -
- 虚拟认证文件 -
` : ` -
- ${mainFlagButton} - - - -
`; - - return ` -
-
-
${typeBadge}${safeFileName}
-
- ${i18n.t('auth_files.file_modified')}: ${new Date(file.modtime).toLocaleString(i18n.currentLanguage === 'zh-CN' ? 'zh-CN' : 'en-US')} - ${i18n.t('auth_files.file_size')}: ${this.formatFileSize(file.size)} -
- -
-
- `; - } - - getFilteredAuthFiles(filterType = this.currentAuthFileFilter) { - const files = Array.isArray(this.cachedAuthFiles) ? this.cachedAuthFiles : []; - const filterValue = (filterType || 'all').toLowerCase(); - const keyword = (this.authFileSearchQuery || '').trim().toLowerCase(); - - return files.filter(file => { - if (!file) return false; - const type = (file.type || 'unknown').toLowerCase(); - const name = (file.name || '').toLowerCase(); - const provider = (file.provider || '').toLowerCase(); - - const matchesType = filterValue === 'all' ? true : type === filterValue; - if (!matchesType) return false; - - if (!keyword) return true; - return name.includes(keyword) || type.includes(keyword) || provider.includes(keyword); - }); - } - - updateAuthFileSearchQuery(value = '') { - const normalized = (value || '').trim(); - if (this.authFileSearchQuery === normalized) { - return; - } - this.authFileSearchQuery = normalized; - this.authFilesPagination.currentPage = 1; - this.renderAuthFilesPage(1); - } - - updateAuthFilesPageSize(value) { - const normalized = this.normalizeAuthFilesPageSize(value); - if (this.authFilesPagination?.pageSize === normalized) { - this.syncAuthFileControls(); - return; - } - this.authFilesPagination.pageSize = normalized; - this.authFilesPagination.currentPage = 1; - try { - localStorage.setItem(this.authFilesPageSizeKey, `${normalized}`); - } catch (error) { - console.warn('Failed to persist auth files page size:', error); - } - this.syncAuthFileControls(); - this.renderAuthFilesPage(1); - } - - syncAuthFileControls() { - const searchInput = document.getElementById('auth-files-search-input'); - if (searchInput && searchInput.value !== this.authFileSearchQuery) { - searchInput.value = this.authFileSearchQuery; - } - - const pageSizeInput = document.getElementById('auth-files-page-size-input'); - const targetSize = this.authFilesPagination?.pageSize || 9; - if (pageSizeInput && parseInt(pageSizeInput.value, 10) !== targetSize) { - pageSizeInput.value = targetSize; - } - } - - bindAuthFilesSearchControl() { - const searchInput = document.getElementById('auth-files-search-input'); - if (!searchInput) return; - - if (searchInput._authFileSearchListener) { - searchInput.removeEventListener('input', searchInput._authFileSearchListener); - } - - const debounced = this.debounce((value) => { - this.updateAuthFileSearchQuery(value); - }, 250); - - const listener = (event) => { - const value = event?.target?.value ?? ''; - debounced(value); - }; - - searchInput._authFileSearchListener = listener; - searchInput.addEventListener('input', listener); - } - - bindAuthFilesPageSizeControl() { - const pageSizeInput = document.getElementById('auth-files-page-size-input'); - if (!pageSizeInput) return; - - if (pageSizeInput._authFilePageSizeListener) { - pageSizeInput.removeEventListener('change', pageSizeInput._authFilePageSizeListener); - } - - const listener = (event) => { - const value = parseInt(event?.target?.value, 10); - if (!Number.isFinite(value)) { - return; - } - this.updateAuthFilesPageSize(value); - }; - - pageSizeInput._authFilePageSizeListener = listener; - pageSizeInput.addEventListener('change', listener); - - if (pageSizeInput._authFilePageSizeBlur) { - pageSizeInput.removeEventListener('blur', pageSizeInput._authFilePageSizeBlur); - } - - const blurListener = () => { - if (!pageSizeInput.value) { - this.syncAuthFileControls(); - } - }; - - pageSizeInput._authFilePageSizeBlur = blurListener; - pageSizeInput.addEventListener('blur', blurListener); - } - - renderAuthFilesPage(page = null) { - const container = document.getElementById('auth-files-list'); - if (!container) return; - - const pageSize = this.authFilesPagination?.pageSize || 9; - const filteredFiles = this.getFilteredAuthFiles(); - const totalItems = filteredFiles.length; - const hasCachedFiles = Array.isArray(this.cachedAuthFiles) && this.cachedAuthFiles.length > 0; - const filterApplied = (this.currentAuthFileFilter && this.currentAuthFileFilter !== 'all'); - const searchApplied = Boolean((this.authFileSearchQuery || '').trim()); - - if (totalItems === 0) { - const titleKey = hasCachedFiles && (filterApplied || searchApplied) - ? 'auth_files.search_empty_title' - : 'auth_files.empty_title'; - const descKey = hasCachedFiles && (filterApplied || searchApplied) - ? 'auth_files.search_empty_desc' - : 'auth_files.empty_desc'; - container.innerHTML = ` -
- -

${i18n.t(titleKey)}

-

${i18n.t(descKey)}

-
- `; - this.authFilesPagination.currentPage = 1; - this.authFilesPagination.totalPages = 1; - this.updatePaginationControls(0); - return; - } - - const totalPages = Math.max(1, Math.ceil(totalItems / pageSize)); - let targetPage = typeof page === 'number' ? page : (this.authFilesPagination.currentPage || 1); - targetPage = Math.max(1, Math.min(targetPage, totalPages)); - - this.authFilesPagination.currentPage = targetPage; - this.authFilesPagination.totalPages = totalPages; - - const startIndex = (targetPage - 1) * pageSize; - const pageFiles = filteredFiles.slice(startIndex, startIndex + pageSize); - container.innerHTML = pageFiles.map(file => this.buildAuthFileItemHtml(file)).join(''); - - this.updatePaginationControls(totalItems); - } - - updatePaginationControls(totalItems = 0) { - const paginationContainer = document.getElementById('auth-files-pagination'); - const infoEl = document.getElementById('auth-files-pagination-info'); - if (!paginationContainer || !infoEl) return; - - const prevBtn = paginationContainer.querySelector('button[data-action="prev"]'); - const nextBtn = paginationContainer.querySelector('button[data-action="next"]'); - const pageSize = this.authFilesPagination?.pageSize || 9; - const totalPages = this.authFilesPagination?.totalPages || 1; - const currentPage = Math.min(this.authFilesPagination?.currentPage || 1, totalPages); - const shouldShow = totalItems > pageSize; - - paginationContainer.style.display = shouldShow ? 'flex' : 'none'; - - const infoParams = totalItems === 0 - ? { current: 0, total: 0, count: 0 } - : { current: currentPage, total: totalPages, count: totalItems }; - infoEl.textContent = i18n.t('auth_files.pagination_info', infoParams); - - if (prevBtn) { - prevBtn.disabled = currentPage <= 1; - } - if (nextBtn) { - nextBtn.disabled = currentPage >= totalPages; - } - } - - bindAuthFilesPaginationEvents() { - const container = document.getElementById('auth-files-pagination'); - if (!container) return; - - const oldListener = container._paginationListener; - if (oldListener) { - container.removeEventListener('click', oldListener); - } - - const listener = (event) => { - const button = event.target.closest('button[data-action]'); - if (!button || !container.contains(button)) return; - event.preventDefault(); - const action = button.dataset.action; - const currentPage = this.authFilesPagination?.currentPage || 1; - if (action === 'prev') { - this.renderAuthFilesPage(currentPage - 1); - } else if (action === 'next') { - this.renderAuthFilesPage(currentPage + 1); - } - }; - - container._paginationListener = listener; - container.addEventListener('click', listener); - } - -// 更新筛选按钮显示 - updateFilterButtons(existingTypes) { - const filterContainer = document.querySelector('.auth-file-filter'); - if (!filterContainer) return; - - // 预定义的按钮顺序和显示文本 - const predefinedTypes = [ - { type: 'all', labelKey: 'auth_files.filter_all' }, - { type: 'qwen', labelKey: 'auth_files.filter_qwen' }, - { type: 'gemini', labelKey: 'auth_files.filter_gemini' }, - { type: 'gemini-cli', labelKey: 'auth_files.filter_gemini-cli' }, - { type: 'aistudio', labelKey: 'auth_files.filter_aistudio' }, - { type: 'claude', labelKey: 'auth_files.filter_claude' }, - { type: 'codex', labelKey: 'auth_files.filter_codex' }, - { type: 'iflow', labelKey: 'auth_files.filter_iflow' }, - { type: 'vertex', labelKey: 'auth_files.filter_vertex' }, - { type: 'empty', labelKey: 'auth_files.filter_empty' } - ]; - - // 获取现有按钮 - const existingButtons = filterContainer.querySelectorAll('.filter-btn'); - const existingButtonTypes = new Set(); - existingButtons.forEach(btn => { - existingButtonTypes.add(btn.dataset.type); - }); - - // 显示/隐藏预定义按钮 - existingButtons.forEach(btn => { - const btnType = btn.dataset.type; - if (existingTypes.has(btnType)) { - btn.style.display = 'inline-block'; - const match = predefinedTypes.find(item => item.type === btnType); - if (match) { - btn.textContent = i18n.t(match.labelKey); - btn.setAttribute('data-i18n-text', match.labelKey); - } - } else { - btn.style.display = 'none'; - } - }); - - // 为未知类型添加新按钮 - const predefinedTypeSet = new Set(predefinedTypes.map(t => t.type)); - existingTypes.forEach(type => { - if (type !== 'all' && !predefinedTypeSet.has(type) && !existingButtonTypes.has(type)) { - // 创建新按钮 - const btn = document.createElement('button'); - btn.className = 'filter-btn'; - btn.dataset.type = type; - - const match = predefinedTypes.find(item => item.type === type); - if (match) { - btn.setAttribute('data-i18n-text', match.labelKey); - btn.textContent = i18n.t(match.labelKey); - } else { - const dynamicKey = `auth_files.filter_${type}`; - btn.setAttribute('data-i18n-text', dynamicKey); - btn.textContent = this.generateDynamicTypeLabel(type); - } - - // 插入到 Empty 按钮之前(如果存在) - const emptyBtn = filterContainer.querySelector('[data-type="empty"]'); - if (emptyBtn) { - filterContainer.insertBefore(btn, emptyBtn); - } else { - filterContainer.appendChild(btn); - } - } - }); - } - - // 处理筛选按钮点击 - handleFilterClick(clickedBtn, options = {}) { - if (!clickedBtn) return; - const { skipRender = false } = options; - const filterBtns = document.querySelectorAll('.auth-file-filter .filter-btn'); - - filterBtns.forEach(b => b.classList.remove('active')); - clickedBtn.classList.add('active'); - - const filterType = clickedBtn.dataset.type; - this.currentAuthFileFilter = filterType || 'all'; - - if (!skipRender) { - this.authFilesPagination.currentPage = 1; - this.renderAuthFilesPage(1); - } - - this.refreshFilterButtonTexts(); - } - - // 生成动态类型标签 - generateDynamicTypeLabel(type) { - if (!type) return ''; - const key = `auth_files.type_${type}`; - const translated = i18n.t(key); - if (translated && translated !== key) { - return translated; - } - if (type.toLowerCase() === 'iflow') return 'iFlow'; - return type.charAt(0).toUpperCase() + type.slice(1); - } - - // 绑定认证文件筛选事件 - bindAuthFileFilterEvents() { - const filterContainer = document.querySelector('.auth-file-filter'); - if (!filterContainer) return; - - // 清理旧的监听器,避免重复绑定 - if (filterContainer._filterListener) { - filterContainer.removeEventListener('click', filterContainer._filterListener); - } - - const listener = (event) => { - const button = event.target.closest('.filter-btn'); - if (!button || !filterContainer.contains(button)) return; - event.preventDefault(); - this.handleFilterClick(button); - }; - - filterContainer._filterListener = listener; - filterContainer.addEventListener('click', listener); - - // 首次渲染时刷新按钮文本 - this.refreshFilterButtonTexts(); - } - - // Apply current filter selection to the list - applyAuthFileFilterState(shouldRender = false) { - const filterContainer = document.querySelector('.auth-file-filter'); - if (!filterContainer) return; - - const currentType = this.currentAuthFileFilter || 'all'; - const buttons = filterContainer.querySelectorAll('.filter-btn'); - if (buttons.length === 0) return; - - let targetButton = null; - buttons.forEach(btn => { - if (btn.dataset.type === currentType) { - targetButton = btn; - } - }); - - if (!targetButton) { - targetButton = filterContainer.querySelector('.filter-btn[data-type="all"]') || buttons[0]; - if (targetButton) { - this.currentAuthFileFilter = targetButton.dataset.type || 'all'; - } - } - - if (targetButton) { - this.handleFilterClick(targetButton, { skipRender: !shouldRender }); - } - } - - // Remove deleted auth files from cache and DOM instantly - removeAuthFileElements(filenames = []) { - if (!Array.isArray(filenames) || filenames.length === 0) { - return; - } - - const removalSet = new Set(filenames); - this.cachedAuthFiles = (this.cachedAuthFiles || []).filter(file => file && !removalSet.has(file.name)); - - if (!this.cachedAuthFiles.length) { - this.authFilesPagination.currentPage = 1; - } - this.renderAuthFilesPage(this.authFilesPagination.currentPage); - } - - // 刷新筛选按钮文本(根据 data-i18n-text) - refreshFilterButtonTexts() { - document.querySelectorAll('.auth-file-filter .filter-btn[data-i18n-text]').forEach(btn => { - const key = btn.getAttribute('data-i18n-text'); - if (key) { - btn.textContent = i18n.t(key); - } - }); - } - - // 绑定认证文件操作按钮事件(使用事件委托) - bindAuthFileActionEvents() { - const container = document.getElementById('auth-files-list'); - if (!container) return; - - // 移除旧的事件监听器(如果存在) - const oldListener = container._authFileActionListener; - if (oldListener) { - container.removeEventListener('click', oldListener); - } - - // 创建新的事件监听器 - const listener = (e) => { - // 查找最近的按钮元素 - const button = e.target.closest('button[data-action]'); - if (!button) return; - - // 获取操作类型和文件名 - const action = button.dataset.action; - const actionsContainer = button.closest('.item-actions'); - if (!actionsContainer) return; - - const filename = actionsContainer.dataset.filename; - if (!filename) return; - - // 根据操作类型调用相应的方法 - switch (action) { - case 'showDetails': - this.showAuthFileDetails(filename); - break; - case 'download': - this.downloadAuthFile(filename); - break; - case 'delete': - this.deleteAuthFile(filename); - break; - } - }; - - // 保存监听器引用以便后续移除 - container._authFileActionListener = listener; - - // 添加事件监听器 - container.addEventListener('click', listener); - } - - // 格式化文件大小 - formatFileSize(bytes) { - if (bytes === 0) return '0 Bytes'; - const k = 1024; - const sizes = ['Bytes', 'KB', 'MB', 'GB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; - } - - // ===== Vertex AI Credential Import ===== - openVertexFilePicker() { - const fileInput = document.getElementById('vertex-file-input'); - if (fileInput) { - fileInput.click(); - } - } - - handleVertexFileSelection(event) { - const fileInput = event?.target; - const file = fileInput?.files?.[0] || null; - - if (fileInput) { - fileInput.value = ''; - } - - if (file && !file.name.toLowerCase().endsWith('.json')) { - this.showNotification(i18n.t('vertex_import.file_required'), 'error'); - this.vertexImportState.file = null; - this.updateVertexFileDisplay(); - this.updateVertexImportButtonState(); - return; - } - - this.vertexImportState.file = file; - this.updateVertexFileDisplay(file ? file.name : ''); - this.updateVertexImportButtonState(); - } - - updateVertexFileDisplay(filename = '') { - const displayInput = document.getElementById('vertex-file-display'); - if (!displayInput) return; - displayInput.value = filename || ''; - } - - updateVertexImportButtonState() { - const importBtn = document.getElementById('vertex-import-btn'); - if (!importBtn) return; - const disabled = !this.vertexImportState.file || this.vertexImportState.loading; - importBtn.disabled = disabled; - } - - async importVertexCredential() { - if (!this.vertexImportState.file) { - this.showNotification(i18n.t('vertex_import.file_required'), 'error'); - return; - } - - const locationInput = document.getElementById('vertex-location'); - const location = locationInput ? locationInput.value.trim() : ''; - const formData = new FormData(); - formData.append('file', this.vertexImportState.file, this.vertexImportState.file.name); - if (location) { - formData.append('location', location); - } - - try { - this.vertexImportState.loading = true; - this.updateVertexImportButtonState(); - - const response = await fetch(`${this.apiUrl}/vertex/import`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${this.managementKey}` - }, - body: formData - }); - - if (!response.ok) { - let errorMessage = `HTTP ${response.status}`; - try { - const errorData = await response.json(); - errorMessage = errorData?.message || errorData?.error || errorMessage; - } catch (parseError) { - const text = await response.text(); - if (text) { - errorMessage = text; - } - } - throw new Error(errorMessage); - } - - const result = await response.json(); - this.showNotification(i18n.t('vertex_import.success'), 'success'); - this.renderVertexImportResult(result); - this.vertexImportState.file = null; - this.updateVertexFileDisplay(); - this.clearCache(); - await this.loadAuthFiles(); - } catch (error) { - this.showNotification(`${i18n.t('notification.upload_failed')}: ${error.message}`, 'error'); - } finally { - this.vertexImportState.loading = false; - this.updateVertexImportButtonState(); - const fileInput = document.getElementById('vertex-file-input'); - if (fileInput) { - fileInput.value = ''; - } - } - } - - renderVertexImportResult(result = null) { - const container = document.getElementById('vertex-import-result'); - const projectEl = document.getElementById('vertex-result-project'); - const emailEl = document.getElementById('vertex-result-email'); - const locationEl = document.getElementById('vertex-result-location'); - const fileEl = document.getElementById('vertex-result-file'); - - if (!container || !projectEl || !emailEl || !locationEl || !fileEl) { - return; - } - - if (!result) { - this.vertexImportState.result = null; - container.style.display = 'none'; - projectEl.textContent = '-'; - emailEl.textContent = '-'; - locationEl.textContent = '-'; - fileEl.textContent = '-'; - return; - } - - this.vertexImportState.result = result; - projectEl.textContent = result.project_id || '-'; - emailEl.textContent = result.email || '-'; - locationEl.textContent = result.location || 'us-central1'; - fileEl.textContent = result['auth-file'] || result.auth_file || '-'; - container.style.display = 'block'; - } - - // 上传认证文件 - uploadAuthFile() { - document.getElementById('auth-file-input').click(); - } - - // 处理文件上传 - async handleFileUpload(event) { - const file = event.target.files[0]; - if (!file) return; - - if (!file.name.endsWith('.json')) { - this.showNotification(i18n.t('auth_files.upload_error_json'), 'error'); - return; - } - - try { - const formData = new FormData(); - formData.append('file', file); - - const response = await fetch(`${this.apiUrl}/auth-files`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${this.managementKey}` - }, - body: formData - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.error || `HTTP ${response.status}`); - } - - this.clearCache(); // 清除缓存 - this.loadAuthFiles(); - this.showNotification(i18n.t('auth_files.upload_success'), 'success'); - } catch (error) { - this.showNotification(`${i18n.t('notification.upload_failed')}: ${error.message}`, 'error'); - } - - // 清空文件输入 - event.target.value = ''; - } - - // 显示认证文件详细信息 - async showAuthFileDetails(filename) { - try { - const response = await fetch(`${this.apiUrl}/auth-files/download?name=${encodeURIComponent(filename)}`, { - headers: { - 'Authorization': `Bearer ${this.managementKey}` - } - }); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); - } - - const jsonData = await response.json(); - - // 格式化JSON数据 - const formattedJson = JSON.stringify(jsonData, null, 2); - - // 显示模态框 - this.showJsonModal(filename, formattedJson); - } catch (error) { - this.showNotification(`读取文件详情失败: ${error.message}`, 'error'); - } - } - - // 显示JSON模态框 - showJsonModal(filename, jsonContent) { - // 创建模态框HTML - const safeFilename = this.escapeHtml(filename || ''); - const modalHtml = ` -
-
-
-

${safeFilename}

- -
-
-
${this.escapeHtml(jsonContent)}
-
- -
-
- `; - - // 移除旧的模态框(如果存在) - const oldModal = document.getElementById('json-modal'); - if (oldModal) { - oldModal.remove(); - } - - // 添加新模态框 - document.body.insertAdjacentHTML('beforeend', modalHtml); - - // 获取模态框元素 - const modal = document.getElementById('json-modal'); - - // 添加点击背景关闭功能 - modal.addEventListener('click', (e) => { - if (e.target === modal) { - this.closeJsonModal(); - } - }); - - // 绑定模态框按钮事件(使用事件委托) - this.bindJsonModalEvents(modal); - } - - // 绑定JSON模态框按钮事件(使用事件委托) - bindJsonModalEvents(modal) { - modal.addEventListener('click', (e) => { - // 查找最近的按钮元素 - const button = e.target.closest('button[data-action]'); - if (!button) return; - - // 获取操作类型 - const action = button.dataset.action; - - // 根据操作类型调用相应的方法 - switch (action) { - case 'copy': - this.copyJsonContent(); - break; - case 'close': - this.closeJsonModal(); - break; - } - }); - } - - // 关闭JSON模态框 - closeJsonModal() { - const modal = document.getElementById('json-modal'); - if (modal) { - modal.remove(); - } - } - - // 复制JSON内容 - copyJsonContent() { - const jsonContent = document.querySelector('.json-content'); - if (jsonContent) { - const text = jsonContent.textContent; - navigator.clipboard.writeText(text).then(() => { - this.showNotification('内容已复制到剪贴板', 'success'); - }).catch(() => { - this.showNotification('复制失败', 'error'); - }); - } - } - - // 下载认证文件 - async downloadAuthFile(filename) { - try { - const response = await fetch(`${this.apiUrl}/auth-files/download?name=${encodeURIComponent(filename)}`, { - headers: { - 'Authorization': `Bearer ${this.managementKey}` - } - }); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); - } - - const blob = await response.blob(); - const url = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - a.click(); - window.URL.revokeObjectURL(url); - - this.showNotification(i18n.t('auth_files.download_success'), 'success'); - } catch (error) { - this.showNotification(`${i18n.t('notification.download_failed')}: ${error.message}`, 'error'); - } - } - - // 删除认证文件 - async deleteAuthFile(filename) { - if (!confirm(`${i18n.t('auth_files.delete_confirm')} "${filename}" 吗?`)) return; - - try { - await this.makeRequest(`/auth-files?name=${encodeURIComponent(filename)}`, { method: 'DELETE' }); - this.removeAuthFileElements([filename]); - this.clearCache(); // 清除缓存 - await this.loadAuthFiles(); - this.showNotification(i18n.t('auth_files.delete_success'), 'success'); - } catch (error) { - this.showNotification(`${i18n.t('notification.delete_failed')}: ${error.message}`, 'error'); - } - } - - // Delete auth files (respect current filter) - async deleteAllAuthFiles() { - const filterType = (this.currentAuthFileFilter || 'all').toLowerCase(); - const isFiltered = filterType !== 'all'; - const typeLabel = this.generateDynamicTypeLabel(filterType); - const confirmMessage = isFiltered - ? i18n.t('auth_files.delete_filtered_confirm').replace('{type}', typeLabel) - : i18n.t('auth_files.delete_all_confirm'); - - if (!confirm(confirmMessage)) return; - - try { - if (!isFiltered) { - const response = await this.makeRequest('/auth-files?all=true', { method: 'DELETE' }); - const currentNames = (this.cachedAuthFiles || []).map(file => file.name).filter(Boolean); - if (currentNames.length > 0) { - this.removeAuthFileElements(currentNames); - } - this.clearCache(); // 清除缓存 - this.currentAuthFileFilter = 'all'; - await this.loadAuthFiles(); - this.showNotification(`${i18n.t('auth_files.delete_all_success')} ${response.deleted} ${i18n.t('auth_files.files_count')}`, 'success'); - return; - } - - const deletableFiles = (this.cachedAuthFiles || []).filter(file => { - if (!file || file.runtime_only) return false; - const fileType = (file.type || 'unknown').toLowerCase(); - return fileType === filterType; - }); - - if (deletableFiles.length === 0) { - this.showNotification(i18n.t('auth_files.delete_filtered_none').replace('{type}', typeLabel), 'info'); - return; - } - - let success = 0; - let failed = 0; - const deletedNames = []; - - for (const file of deletableFiles) { - try { - await this.makeRequest(`/auth-files?name=${encodeURIComponent(file.name)}`, { method: 'DELETE' }); - success++; - deletedNames.push(file.name); - } catch (error) { - console.error('删除认证文件失败:', file?.name, error); - failed++; - } - } - - if (deletedNames.length > 0) { - this.removeAuthFileElements(deletedNames); - } - - this.clearCache(); // 清除缓存 - this.currentAuthFileFilter = 'all'; - await this.loadAuthFiles(); - - if (failed === 0) { - const successMsg = i18n.t('auth_files.delete_filtered_success') - .replace('{count}', success) - .replace('{type}', typeLabel); - this.showNotification(successMsg, 'success'); - } else { - const warningMsg = i18n.t('auth_files.delete_filtered_partial') - .replace('{success}', success) - .replace('{failed}', failed) - .replace('{type}', typeLabel); - this.showNotification(warningMsg, 'warning'); - } - } catch (error) { - this.showNotification(`${i18n.t('notification.delete_failed')}: ${error.message}`, 'error'); - } - } - - - - - - // ===== Codex OAuth 相关方法 ===== - - // 开始 Codex OAuth 流程 - async startCodexOAuth() { - try { - const response = await this.makeRequest('/codex-auth-url?is_webui=1'); - const authUrl = response.url; - const state = this.extractStateFromUrl(authUrl); - - // 显示授权链接 - const urlInput = document.getElementById('codex-oauth-url'); - const content = document.getElementById('codex-oauth-content'); - const status = document.getElementById('codex-oauth-status'); - - if (urlInput) { - urlInput.value = authUrl; - } - if (content) { - content.style.display = 'block'; - } - if (status) { - status.textContent = i18n.t('auth_login.codex_oauth_status_waiting'); - status.style.color = 'var(--warning-text)'; - } - - // 开始轮询认证状态 - this.startCodexOAuthPolling(state); - - } catch (error) { - this.showNotification(`${i18n.t('auth_login.codex_oauth_start_error')} ${error.message}`, 'error'); - } - } - - // 从 URL 中提取 state 参数 - extractStateFromUrl(url) { - try { - const urlObj = new URL(url); - return urlObj.searchParams.get('state'); - } catch (error) { - console.error('Failed to extract state from URL:', error); - return null; - } - } - - // 打开 Codex 授权链接 - openCodexLink() { - const urlInput = document.getElementById('codex-oauth-url'); - if (urlInput && urlInput.value) { - window.open(urlInput.value, '_blank'); - } - } - - // 复制 Codex 授权链接 - async copyCodexLink() { - const urlInput = document.getElementById('codex-oauth-url'); - if (urlInput && urlInput.value) { - try { - await navigator.clipboard.writeText(urlInput.value); - this.showNotification(i18n.t('notification.link_copied'), 'success'); - } catch (error) { - // 降级方案:使用传统的复制方法 - urlInput.select(); - document.execCommand('copy'); - this.showNotification(i18n.t('notification.link_copied'), 'success'); - } - } - } - - // 开始轮询 OAuth 状态 - startCodexOAuthPolling(state) { - if (!state) { - this.showNotification(i18n.t('auth_login.missing_state'), 'error'); - return; - } - - const pollInterval = setInterval(async () => { - try { - const response = await this.makeRequest(`/get-auth-status?state=${encodeURIComponent(state)}`); - const status = response.status; - const statusElement = document.getElementById('codex-oauth-status'); - - if (status === 'ok') { - // 认证成功 - clearInterval(pollInterval); - // 隐藏授权链接相关内容,恢复到初始状态 - this.resetCodexOAuthUI(); - // 显示成功通知 - this.showNotification(i18n.t('auth_login.codex_oauth_status_success'), 'success'); - // 刷新认证文件列表 - this.loadAuthFiles(); - } else if (status === 'error') { - // 认证失败 - clearInterval(pollInterval); - const errorMessage = response.error || 'Unknown error'; - // 显示错误信息 - if (statusElement) { - statusElement.textContent = `${i18n.t('auth_login.codex_oauth_status_error')} ${errorMessage}`; - statusElement.style.color = 'var(--error-text)'; - } - this.showNotification(`${i18n.t('auth_login.codex_oauth_status_error')} ${errorMessage}`, 'error'); - // 3秒后重置UI,让用户能够重新开始 - setTimeout(() => { - this.resetCodexOAuthUI(); - }, 3000); - } else if (status === 'wait') { - // 继续等待 - if (statusElement) { - statusElement.textContent = i18n.t('auth_login.codex_oauth_status_waiting'); - statusElement.style.color = 'var(--warning-text)'; - } - } - } catch (error) { - clearInterval(pollInterval); - const statusElement = document.getElementById('codex-oauth-status'); - if (statusElement) { - statusElement.textContent = `${i18n.t('auth_login.codex_oauth_polling_error')} ${error.message}`; - statusElement.style.color = 'var(--error-text)'; - } - this.showNotification(`${i18n.t('auth_login.codex_oauth_polling_error')} ${error.message}`, 'error'); - // 3秒后重置UI,让用户能够重新开始 - setTimeout(() => { - this.resetCodexOAuthUI(); - }, 3000); - } - }, 2000); // 每2秒轮询一次 - - // 设置超时,5分钟后停止轮询 - setTimeout(() => { - clearInterval(pollInterval); - }, 5 * 60 * 1000); - } - - // 重置 Codex OAuth UI 到初始状态 - resetCodexOAuthUI() { - const urlInput = document.getElementById('codex-oauth-url'); - const content = document.getElementById('codex-oauth-content'); - const status = document.getElementById('codex-oauth-status'); - - // 清空并隐藏授权链接输入框 - if (urlInput) { - urlInput.value = ''; - } - - // 隐藏整个授权链接内容区域 - if (content) { - content.style.display = 'none'; - } - - // 清空状态显示 - if (status) { - status.textContent = ''; - status.style.color = ''; - status.className = ''; - } - } - - // ===== Anthropic OAuth 相关方法 ===== - - // 开始 Anthropic OAuth 流程 - async startAnthropicOAuth() { - try { - const response = await this.makeRequest('/anthropic-auth-url?is_webui=1'); - const authUrl = response.url; - const state = this.extractStateFromUrl(authUrl); - - // 显示授权链接 - const urlInput = document.getElementById('anthropic-oauth-url'); - const content = document.getElementById('anthropic-oauth-content'); - const status = document.getElementById('anthropic-oauth-status'); - - if (urlInput) { - urlInput.value = authUrl; - } - if (content) { - content.style.display = 'block'; - } - if (status) { - status.textContent = i18n.t('auth_login.anthropic_oauth_status_waiting'); - status.style.color = 'var(--warning-text)'; - } - - // 开始轮询认证状态 - this.startAnthropicOAuthPolling(state); - - } catch (error) { - this.showNotification(`${i18n.t('auth_login.anthropic_oauth_start_error')} ${error.message}`, 'error'); - } - } - - // 打开 Anthropic 授权链接 - openAnthropicLink() { - const urlInput = document.getElementById('anthropic-oauth-url'); - if (urlInput && urlInput.value) { - window.open(urlInput.value, '_blank'); - } - } - - // 复制 Anthropic 授权链接 - async copyAnthropicLink() { - const urlInput = document.getElementById('anthropic-oauth-url'); - if (urlInput && urlInput.value) { - try { - await navigator.clipboard.writeText(urlInput.value); - this.showNotification(i18n.t('notification.link_copied'), 'success'); - } catch (error) { - // 降级方案:使用传统的复制方法 - urlInput.select(); - document.execCommand('copy'); - this.showNotification(i18n.t('notification.link_copied'), 'success'); - } - } - } - - // 开始轮询 Anthropic OAuth 状态 - startAnthropicOAuthPolling(state) { - if (!state) { - this.showNotification(i18n.t('auth_login.missing_state'), 'error'); - return; - } - - const pollInterval = setInterval(async () => { - try { - const response = await this.makeRequest(`/get-auth-status?state=${encodeURIComponent(state)}`); - const status = response.status; - const statusElement = document.getElementById('anthropic-oauth-status'); - - if (status === 'ok') { - // 认证成功 - clearInterval(pollInterval); - // 隐藏授权链接相关内容,恢复到初始状态 - this.resetAnthropicOAuthUI(); - // 显示成功通知 - this.showNotification(i18n.t('auth_login.anthropic_oauth_status_success'), 'success'); - // 刷新认证文件列表 - this.loadAuthFiles(); - } else if (status === 'error') { - // 认证失败 - clearInterval(pollInterval); - const errorMessage = response.error || 'Unknown error'; - // 显示错误信息 - if (statusElement) { - statusElement.textContent = `${i18n.t('auth_login.anthropic_oauth_status_error')} ${errorMessage}`; - statusElement.style.color = 'var(--error-text)'; - } - this.showNotification(`${i18n.t('auth_login.anthropic_oauth_status_error')} ${errorMessage}`, 'error'); - // 3秒后重置UI,让用户能够重新开始 - setTimeout(() => { - this.resetAnthropicOAuthUI(); - }, 3000); - } else if (status === 'wait') { - // 继续等待 - if (statusElement) { - statusElement.textContent = i18n.t('auth_login.anthropic_oauth_status_waiting'); - statusElement.style.color = 'var(--warning-text)'; - } - } - } catch (error) { - clearInterval(pollInterval); - const statusElement = document.getElementById('anthropic-oauth-status'); - if (statusElement) { - statusElement.textContent = `${i18n.t('auth_login.anthropic_oauth_polling_error')} ${error.message}`; - statusElement.style.color = 'var(--error-text)'; - } - this.showNotification(`${i18n.t('auth_login.anthropic_oauth_polling_error')} ${error.message}`, 'error'); - // 3秒后重置UI,让用户能够重新开始 - setTimeout(() => { - this.resetAnthropicOAuthUI(); - }, 3000); - } - }, 2000); // 每2秒轮询一次 - - // 设置超时,5分钟后停止轮询 - setTimeout(() => { - clearInterval(pollInterval); - }, 5 * 60 * 1000); - } - - // 重置 Anthropic OAuth UI 到初始状态 - resetAnthropicOAuthUI() { - const urlInput = document.getElementById('anthropic-oauth-url'); - const content = document.getElementById('anthropic-oauth-content'); - const status = document.getElementById('anthropic-oauth-status'); - - // 清空并隐藏授权链接输入框 - if (urlInput) { - urlInput.value = ''; - } - - // 隐藏整个授权链接内容区域 - if (content) { - content.style.display = 'none'; - } - - // 清空状态显示 - if (status) { - status.textContent = ''; - status.style.color = ''; - status.className = ''; - } - } - - // ===== Gemini CLI OAuth 相关方法 ===== - - // 开始 Gemini CLI OAuth 流程 - async startGeminiCliOAuth() { - try { - // 获取项目 ID(可选) - const projectId = document.getElementById('gemini-cli-project-id').value.trim(); - - // 构建请求 URL - let requestUrl = '/gemini-cli-auth-url?is_webui=1'; - if (projectId) { - requestUrl += `&project_id=${encodeURIComponent(projectId)}`; - } - - const response = await this.makeRequest(requestUrl); - const authUrl = response.url; - const state = this.extractStateFromUrl(authUrl); - - // 显示授权链接 - const urlInput = document.getElementById('gemini-cli-oauth-url'); - const content = document.getElementById('gemini-cli-oauth-content'); - const status = document.getElementById('gemini-cli-oauth-status'); - - if (urlInput) { - urlInput.value = authUrl; - } - if (content) { - content.style.display = 'block'; - } - if (status) { - status.textContent = i18n.t('auth_login.gemini_cli_oauth_status_waiting'); - status.style.color = 'var(--warning-text)'; - } - - // 开始轮询认证状态 - this.startGeminiCliOAuthPolling(state); - - } catch (error) { - this.showNotification(`${i18n.t('auth_login.gemini_cli_oauth_start_error')} ${error.message}`, 'error'); - } - } - - // 打开 Gemini CLI 授权链接 - openGeminiCliLink() { - const urlInput = document.getElementById('gemini-cli-oauth-url'); - if (urlInput && urlInput.value) { - window.open(urlInput.value, '_blank'); - } - } - - // 复制 Gemini CLI 授权链接 - async copyGeminiCliLink() { - const urlInput = document.getElementById('gemini-cli-oauth-url'); - if (urlInput && urlInput.value) { - try { - await navigator.clipboard.writeText(urlInput.value); - this.showNotification(i18n.t('notification.link_copied'), 'success'); - } catch (error) { - // 降级方案:使用传统的复制方法 - urlInput.select(); - document.execCommand('copy'); - this.showNotification(i18n.t('notification.link_copied'), 'success'); - } - } - } - - // 开始轮询 Gemini CLI OAuth 状态 - startGeminiCliOAuthPolling(state) { - if (!state) { - this.showNotification(i18n.t('auth_login.missing_state'), 'error'); - return; - } - - const pollInterval = setInterval(async () => { - try { - const response = await this.makeRequest(`/get-auth-status?state=${encodeURIComponent(state)}`); - const status = response.status; - const statusElement = document.getElementById('gemini-cli-oauth-status'); - - if (status === 'ok') { - // 认证成功 - clearInterval(pollInterval); - // 隐藏授权链接相关内容,恢复到初始状态 - this.resetGeminiCliOAuthUI(); - // 显示成功通知 - this.showNotification(i18n.t('auth_login.gemini_cli_oauth_status_success'), 'success'); - // 刷新认证文件列表 - this.loadAuthFiles(); - } else if (status === 'error') { - // 认证失败 - clearInterval(pollInterval); - const errorMessage = response.error || 'Unknown error'; - // 显示错误信息 - if (statusElement) { - statusElement.textContent = `${i18n.t('auth_login.gemini_cli_oauth_status_error')} ${errorMessage}`; - statusElement.style.color = 'var(--error-text)'; - } - this.showNotification(`${i18n.t('auth_login.gemini_cli_oauth_status_error')} ${errorMessage}`, 'error'); - // 3秒后重置UI,让用户能够重新开始 - setTimeout(() => { - this.resetGeminiCliOAuthUI(); - }, 3000); - } else if (status === 'wait') { - // 继续等待 - if (statusElement) { - statusElement.textContent = i18n.t('auth_login.gemini_cli_oauth_status_waiting'); - statusElement.style.color = 'var(--warning-text)'; - } - } - } catch (error) { - clearInterval(pollInterval); - const statusElement = document.getElementById('gemini-cli-oauth-status'); - if (statusElement) { - statusElement.textContent = `${i18n.t('auth_login.gemini_cli_oauth_polling_error')} ${error.message}`; - statusElement.style.color = 'var(--error-text)'; - } - this.showNotification(`${i18n.t('auth_login.gemini_cli_oauth_polling_error')} ${error.message}`, 'error'); - // 3秒后重置UI,让用户能够重新开始 - setTimeout(() => { - this.resetGeminiCliOAuthUI(); - }, 3000); - } - }, 2000); // 每2秒轮询一次 - - // 设置超时,5分钟后停止轮询 - setTimeout(() => { - clearInterval(pollInterval); - }, 5 * 60 * 1000); - } - - // 重置 Gemini CLI OAuth UI 到初始状态 - resetGeminiCliOAuthUI() { - const urlInput = document.getElementById('gemini-cli-oauth-url'); - const content = document.getElementById('gemini-cli-oauth-content'); - const status = document.getElementById('gemini-cli-oauth-status'); - - // 清空并隐藏授权链接输入框 - if (urlInput) { - urlInput.value = ''; - } - - // 隐藏整个授权链接内容区域 - if (content) { - content.style.display = 'none'; - } - - // 清空状态显示 - if (status) { - status.textContent = ''; - status.style.color = ''; - status.className = ''; - } - } - - // ===== Qwen OAuth 相关方法 ===== - - // 开始 Qwen OAuth 流程 - async startQwenOAuth() { - try { - const response = await this.makeRequest('/qwen-auth-url?is_webui=1'); - const authUrl = response.url; - const state = this.extractStateFromUrl(authUrl); - - // 显示授权链接 - const urlInput = document.getElementById('qwen-oauth-url'); - const content = document.getElementById('qwen-oauth-content'); - const status = document.getElementById('qwen-oauth-status'); - - if (urlInput) { - urlInput.value = authUrl; - } - if (content) { - content.style.display = 'block'; - } - if (status) { - status.textContent = i18n.t('auth_login.qwen_oauth_status_waiting'); - status.style.color = 'var(--warning-text)'; - } - - // 开始轮询认证状态 - this.startQwenOAuthPolling(response.state); - - } catch (error) { - this.showNotification(`${i18n.t('auth_login.qwen_oauth_start_error')} ${error.message}`, 'error'); - } - } - - // 打开 Qwen 授权链接 - openQwenLink() { - const urlInput = document.getElementById('qwen-oauth-url'); - if (urlInput && urlInput.value) { - window.open(urlInput.value, '_blank'); - } - } - - // 复制 Qwen 授权链接 - async copyQwenLink() { - const urlInput = document.getElementById('qwen-oauth-url'); - if (urlInput && urlInput.value) { - try { - await navigator.clipboard.writeText(urlInput.value); - this.showNotification(i18n.t('notification.link_copied'), 'success'); - } catch (error) { - // 降级方案:使用传统的复制方法 - urlInput.select(); - document.execCommand('copy'); - this.showNotification(i18n.t('notification.link_copied'), 'success'); - } - } - } - - // 开始轮询 Qwen OAuth 状态 - startQwenOAuthPolling(state) { - if (!state) { - this.showNotification(i18n.t('auth_login.missing_state'), 'error'); - return; - } - - const pollInterval = setInterval(async () => { - try { - const response = await this.makeRequest(`/get-auth-status?state=${encodeURIComponent(state)}`); - const status = response.status; - const statusElement = document.getElementById('qwen-oauth-status'); - - if (status === 'ok') { - // 认证成功 - clearInterval(pollInterval); - // 隐藏授权链接相关内容,恢复到初始状态 - this.resetQwenOAuthUI(); - // 显示成功通知 - this.showNotification(i18n.t('auth_login.qwen_oauth_status_success'), 'success'); - // 刷新认证文件列表 - this.loadAuthFiles(); - } else if (status === 'error') { - // 认证失败 - clearInterval(pollInterval); - const errorMessage = response.error || 'Unknown error'; - // 显示错误信息 - if (statusElement) { - statusElement.textContent = `${i18n.t('auth_login.qwen_oauth_status_error')} ${errorMessage}`; - statusElement.style.color = 'var(--error-text)'; - } - this.showNotification(`${i18n.t('auth_login.qwen_oauth_status_error')} ${errorMessage}`, 'error'); - // 3秒后重置UI,让用户能够重新开始 - setTimeout(() => { - this.resetQwenOAuthUI(); - }, 3000); - } else if (status === 'wait') { - // 继续等待 - if (statusElement) { - statusElement.textContent = i18n.t('auth_login.qwen_oauth_status_waiting'); - statusElement.style.color = 'var(--warning-text)'; - } - } - } catch (error) { - clearInterval(pollInterval); - const statusElement = document.getElementById('qwen-oauth-status'); - if (statusElement) { - statusElement.textContent = `${i18n.t('auth_login.qwen_oauth_polling_error')} ${error.message}`; - statusElement.style.color = 'var(--error-text)'; - } - this.showNotification(`${i18n.t('auth_login.qwen_oauth_polling_error')} ${error.message}`, 'error'); - // 3秒后重置UI,让用户能够重新开始 - setTimeout(() => { - this.resetQwenOAuthUI(); - }, 3000); - } - }, 2000); // 每2秒轮询一次 - - // 设置超时,5分钟后停止轮询 - setTimeout(() => { - clearInterval(pollInterval); - }, 5 * 60 * 1000); - } - - // 重置 Qwen OAuth UI 到初始状态 - resetQwenOAuthUI() { - const urlInput = document.getElementById('qwen-oauth-url'); - const content = document.getElementById('qwen-oauth-content'); - const status = document.getElementById('qwen-oauth-status'); - - // 清空并隐藏授权链接输入框 - if (urlInput) { - urlInput.value = ''; - } - - // 隐藏整个授权链接内容区域 - if (content) { - content.style.display = 'none'; - } - - // 清空状态显示 - if (status) { - status.textContent = ''; - status.style.color = ''; - status.className = ''; - } - } - - // ===== iFlow OAuth 相关方法 ===== - - // 开始 iFlow OAuth 流程 - async startIflowOAuth() { - try { - const response = await this.makeRequest('/iflow-auth-url?is_webui=1'); - const authUrl = response.url; - const state = this.extractStateFromUrl(authUrl); - - // 显示授权链接 - const urlInput = document.getElementById('iflow-oauth-url'); - const content = document.getElementById('iflow-oauth-content'); - const status = document.getElementById('iflow-oauth-status'); - - if (urlInput) { - urlInput.value = authUrl; - } - if (content) { - content.style.display = 'block'; - } - if (status) { - status.textContent = i18n.t('auth_login.iflow_oauth_status_waiting'); - status.style.color = 'var(--warning-text)'; - } - - // 开始轮询认证状态 - this.startIflowOAuthPolling(state); - - } catch (error) { - this.showNotification(`${i18n.t('auth_login.iflow_oauth_start_error')} ${error.message}`, 'error'); - } - } - - // 打开 iFlow 授权链接 - openIflowLink() { - const urlInput = document.getElementById('iflow-oauth-url'); - if (urlInput && urlInput.value) { - window.open(urlInput.value, '_blank'); - } - } - - // 复制 iFlow 授权链接 - async copyIflowLink() { - const urlInput = document.getElementById('iflow-oauth-url'); - if (urlInput && urlInput.value) { - try { - await navigator.clipboard.writeText(urlInput.value); - this.showNotification(i18n.t('notification.link_copied'), 'success'); - } catch (error) { - // 降级方案:使用传统的复制方法 - urlInput.select(); - document.execCommand('copy'); - this.showNotification(i18n.t('notification.link_copied'), 'success'); - } - } - } - - // 开始轮询 iFlow OAuth 状态 - startIflowOAuthPolling(state) { - if (!state) { - this.showNotification(i18n.t('auth_login.missing_state'), 'error'); - return; - } - - const pollInterval = setInterval(async () => { - try { - const response = await this.makeRequest(`/get-auth-status?state=${encodeURIComponent(state)}`); - const status = response.status; - const statusElement = document.getElementById('iflow-oauth-status'); - - if (status === 'ok') { - // 认证成功 - clearInterval(pollInterval); - // 隐藏授权链接相关内容,恢复到初始状态 - this.resetIflowOAuthUI(); - // 显示成功通知 - this.showNotification(i18n.t('auth_login.iflow_oauth_status_success'), 'success'); - // 刷新认证文件列表 - this.loadAuthFiles(); - } else if (status === 'error') { - // 认证失败 - clearInterval(pollInterval); - const errorMessage = response.error || 'Unknown error'; - // 显示错误信息 - if (statusElement) { - statusElement.textContent = `${i18n.t('auth_login.iflow_oauth_status_error')} ${errorMessage}`; - statusElement.style.color = 'var(--error-text)'; - } - this.showNotification(`${i18n.t('auth_login.iflow_oauth_status_error')} ${errorMessage}`, 'error'); - // 3秒后重置UI,让用户能够重新开始 - setTimeout(() => { - this.resetIflowOAuthUI(); - }, 3000); - } else if (status === 'wait') { - // 继续等待 - if (statusElement) { - statusElement.textContent = i18n.t('auth_login.iflow_oauth_status_waiting'); - statusElement.style.color = 'var(--warning-text)'; - } - } - } catch (error) { - clearInterval(pollInterval); - const statusElement = document.getElementById('iflow-oauth-status'); - if (statusElement) { - statusElement.textContent = `${i18n.t('auth_login.iflow_oauth_polling_error')} ${error.message}`; - statusElement.style.color = 'var(--error-text)'; - } - this.showNotification(`${i18n.t('auth_login.iflow_oauth_polling_error')} ${error.message}`, 'error'); - // 3秒后重置UI,让用户能够重新开始 - setTimeout(() => { - this.resetIflowOAuthUI(); - }, 3000); - } - }, 2000); // 每2秒轮询一次 - - // 设置超时,5分钟后停止轮询 - setTimeout(() => { - clearInterval(pollInterval); - }, 5 * 60 * 1000); - } - - // 重置 iFlow OAuth UI 到初始状态 - resetIflowOAuthUI() { - const urlInput = document.getElementById('iflow-oauth-url'); - const content = document.getElementById('iflow-oauth-content'); - const status = document.getElementById('iflow-oauth-status'); - - // 清空并隐藏授权链接输入框 - if (urlInput) { - urlInput.value = ''; - } - - // 隐藏整个授权链接内容区域 - if (content) { - content.style.display = 'none'; - } - - // 清空状态显示 - if (status) { - status.textContent = ''; - status.style.color = ''; - status.className = ''; - } - } - // ===== 使用统计相关方法 ===== // 使用统计状态 requestsChart = null; tokensChart = null; currentUsageData = null; - chartLineSelections = ['none', 'none', 'none']; - chartLineSelectIds = ['chart-line-select-0', 'chart-line-select-1', 'chart-line-select-2']; - chartLineStyles = [ - { borderColor: '#3b82f6', backgroundColor: 'rgba(59, 130, 246, 0.15)' }, - { borderColor: '#a855f7', backgroundColor: 'rgba(168, 85, 247, 0.15)' }, - { borderColor: '#10b981', backgroundColor: 'rgba(16, 185, 129, 0.15)' } - ]; - - // 获取API密钥的统计信息 - async getKeyStats() { - try { - const response = await this.makeRequest('/usage'); - const usage = response?.usage || null; - - if (!usage) { - return {}; - } - - const sourceStats = {}; - const apis = usage.apis || {}; - - Object.values(apis).forEach(apiEntry => { - const models = apiEntry.models || {}; - - Object.values(models).forEach(modelEntry => { - const details = modelEntry.details || []; - - details.forEach(detail => { - const source = detail.source; - if (!source) return; - - if (!sourceStats[source]) { - sourceStats[source] = { - success: 0, - failure: 0 - }; - } - - const isFailed = detail.failed === true; - if (isFailed) { - sourceStats[source].failure += 1; - } else { - sourceStats[source].success += 1; - } - }); - }); - }); - - return sourceStats; - } catch (error) { - console.error('获取统计信息失败:', error); - return {}; - } - } - - // 加载使用统计 - async loadUsageStats(usageData = null) { - try { - let usage = usageData; - // 如果没有传入usage数据,则调用API获取 - if (!usage) { - const response = await this.makeRequest('/usage'); - usage = response?.usage || null; - } - this.currentUsageData = usage; - - if (!usage) { - throw new Error('usage payload missing'); - } - - // 更新概览卡片 - this.updateUsageOverview(usage); - this.updateChartLineSelectors(usage); - - // 读取当前图表周期 - const requestsHourActive = document.getElementById('requests-hour-btn')?.classList.contains('active'); - const tokensHourActive = document.getElementById('tokens-hour-btn')?.classList.contains('active'); - const requestsPeriod = requestsHourActive ? 'hour' : 'day'; - const tokensPeriod = tokensHourActive ? 'hour' : 'day'; - - // 初始化图表(使用当前周期) - this.initializeRequestsChart(requestsPeriod); - this.initializeTokensChart(tokensPeriod); - - // 更新API详细统计表格 - this.updateApiStatsTable(usage); - - } catch (error) { - console.error('加载使用统计失败:', error); - this.currentUsageData = null; - this.updateChartLineSelectors(null); - - // 清空概览数据 - ['total-requests', 'success-requests', 'failed-requests', 'total-tokens'].forEach(id => { - const el = document.getElementById(id); - if (el) el.textContent = '-'; - }); - - // 清空图表 - if (this.requestsChart) { - this.requestsChart.destroy(); - this.requestsChart = null; - } - if (this.tokensChart) { - this.tokensChart.destroy(); - this.tokensChart = null; - } - - const tableElement = document.getElementById('api-stats-table'); - if (tableElement) { - tableElement.innerHTML = `
${i18n.t('usage_stats.loading_error')}: ${error.message}
`; - } - } - } - - // 更新使用统计概览 - updateUsageOverview(data) { - const safeData = data || {}; - document.getElementById('total-requests').textContent = safeData.total_requests ?? 0; - document.getElementById('success-requests').textContent = safeData.success_count ?? 0; - document.getElementById('failed-requests').textContent = safeData.failure_count ?? 0; - document.getElementById('total-tokens').textContent = safeData.total_tokens ?? 0; - } - - getModelNamesFromUsage(usage) { - if (!usage) { - return []; - } - const apis = usage.apis || {}; - const names = new Set(); - Object.values(apis).forEach(apiEntry => { - const models = apiEntry.models || {}; - Object.keys(models).forEach(modelName => { - if (modelName) { - names.add(modelName); - } - }); - }); - return Array.from(names).sort((a, b) => a.localeCompare(b)); - } - - updateChartLineSelectors(usage) { - const modelNames = this.getModelNamesFromUsage(usage); - const selectors = this.chartLineSelectIds - .map(id => document.getElementById(id)) - .filter(Boolean); - - if (!selectors.length) { - this.chartLineSelections = ['none', 'none', 'none']; - return; - } - - const optionsFragment = () => { - const fragment = document.createDocumentFragment(); - const hiddenOption = document.createElement('option'); - hiddenOption.value = 'none'; - hiddenOption.textContent = i18n.t('usage_stats.chart_line_hidden'); - fragment.appendChild(hiddenOption); - modelNames.forEach(name => { - const option = document.createElement('option'); - option.value = name; - option.textContent = name; - fragment.appendChild(option); - }); - return fragment; - }; - - const hasModels = modelNames.length > 0; - selectors.forEach(select => { - select.innerHTML = ''; - select.appendChild(optionsFragment()); - select.disabled = !hasModels; - }); - - if (!hasModels) { - this.chartLineSelections = ['none', 'none', 'none']; - selectors.forEach(select => { - select.value = 'none'; - }); - return; - } - - const nextSelections = Array.isArray(this.chartLineSelections) - ? [...this.chartLineSelections] - : ['none', 'none', 'none']; - - const validNames = new Set(modelNames); - let hasActiveSelection = false; - for (let i = 0; i < nextSelections.length; i++) { - const selection = nextSelections[i]; - if (selection && selection !== 'none' && !validNames.has(selection)) { - nextSelections[i] = 'none'; - } - if (nextSelections[i] !== 'none') { - hasActiveSelection = true; - } - } - - if (!hasActiveSelection) { - modelNames.slice(0, nextSelections.length).forEach((name, index) => { - nextSelections[index] = name; - }); - } - - this.chartLineSelections = nextSelections; - selectors.forEach((select, index) => { - const value = this.chartLineSelections[index] || 'none'; - select.value = value; - }); - } - - handleChartLineSelectionChange(index, value) { - if (!Array.isArray(this.chartLineSelections)) { - this.chartLineSelections = ['none', 'none', 'none']; - } - if (index < 0 || index >= this.chartLineSelections.length) { - return; - } - const normalized = value || 'none'; - if (this.chartLineSelections[index] === normalized) { - return; - } - this.chartLineSelections[index] = normalized; - this.refreshChartsForSelections(); - } - - refreshChartsForSelections() { - if (!this.currentUsageData) { - return; - } - const requestsHourActive = document.getElementById('requests-hour-btn')?.classList.contains('active'); - const tokensHourActive = document.getElementById('tokens-hour-btn')?.classList.contains('active'); - const requestsPeriod = requestsHourActive ? 'hour' : 'day'; - const tokensPeriod = tokensHourActive ? 'hour' : 'day'; - - if (this.requestsChart) { - this.requestsChart.data = this.getRequestsChartData(requestsPeriod); - this.requestsChart.update(); - } else { - this.initializeRequestsChart(requestsPeriod); - } - - if (this.tokensChart) { - this.tokensChart.data = this.getTokensChartData(tokensPeriod); - this.tokensChart.update(); - } else { - this.initializeTokensChart(tokensPeriod); - } - } - - getActiveChartLineSelections() { - if (!Array.isArray(this.chartLineSelections)) { - this.chartLineSelections = ['none', 'none', 'none']; - } - return this.chartLineSelections - .map((value, index) => ({ model: value, index })) - .filter(item => item.model && item.model !== 'none'); - } - - // 收集所有请求明细,供图表等复用 - collectUsageDetailsFromUsage(usage) { - if (!usage) { - return []; - } - const apis = usage.apis || {}; - const details = []; - Object.values(apis).forEach(apiEntry => { - const models = apiEntry.models || {}; - Object.entries(models).forEach(([modelName, modelEntry]) => { - const modelDetails = Array.isArray(modelEntry.details) ? modelEntry.details : []; - modelDetails.forEach(detail => { - if (detail && detail.timestamp) { - details.push({ - ...detail, - __modelName: modelName - }); - } - }); - }); - }); - return details; - } - - collectUsageDetails() { - return this.collectUsageDetailsFromUsage(this.currentUsageData); - } - - createHourlyBucketMeta() { - const hourMs = 60 * 60 * 1000; - const now = new Date(); - const currentHour = new Date(now); - currentHour.setMinutes(0, 0, 0); - - const earliestBucket = new Date(currentHour); - earliestBucket.setHours(earliestBucket.getHours() - 23); - const earliestTime = earliestBucket.getTime(); - const labels = []; - for (let i = 0; i < 24; i++) { - const bucketStart = earliestTime + i * hourMs; - labels.push(this.formatHourLabel(new Date(bucketStart))); - } - - return { - labels, - earliestTime, - bucketSize: hourMs, - lastBucketTime: earliestTime + (labels.length - 1) * hourMs - }; - } - - buildHourlySeriesByModel(metric = 'requests') { - const meta = this.createHourlyBucketMeta(); - const details = this.collectUsageDetails(); - const dataByModel = new Map(); - let hasData = false; - - if (!details.length) { - return { labels: meta.labels, dataByModel, hasData }; - } - - details.forEach(detail => { - const timestamp = Date.parse(detail.timestamp); - if (Number.isNaN(timestamp)) { - return; - } - - const normalized = new Date(timestamp); - normalized.setMinutes(0, 0, 0); - const bucketStart = normalized.getTime(); - if (bucketStart < meta.earliestTime || bucketStart > meta.lastBucketTime) { - return; - } - - const bucketIndex = Math.floor((bucketStart - meta.earliestTime) / meta.bucketSize); - if (bucketIndex < 0 || bucketIndex >= meta.labels.length) { - return; - } - - const modelName = detail.__modelName || 'Unknown'; - if (!dataByModel.has(modelName)) { - dataByModel.set(modelName, new Array(meta.labels.length).fill(0)); - } - - const bucketValues = dataByModel.get(modelName); - if (metric === 'tokens') { - bucketValues[bucketIndex] += this.extractTotalTokens(detail); - } else { - bucketValues[bucketIndex] += 1; - } - hasData = true; - }); - - return { labels: meta.labels, dataByModel, hasData }; - } - - buildDailySeriesByModel(metric = 'requests') { - const details = this.collectUsageDetails(); - const valuesByModel = new Map(); - const labelsSet = new Set(); - let hasData = false; - - if (!details.length) { - return { labels: [], dataByModel: new Map(), hasData }; - } - - details.forEach(detail => { - const timestamp = Date.parse(detail.timestamp); - if (Number.isNaN(timestamp)) { - return; - } - const dayLabel = this.formatDayLabel(new Date(timestamp)); - if (!dayLabel) { - return; - } - - const modelName = detail.__modelName || 'Unknown'; - if (!valuesByModel.has(modelName)) { - valuesByModel.set(modelName, new Map()); - } - const modelDayMap = valuesByModel.get(modelName); - const increment = metric === 'tokens' ? this.extractTotalTokens(detail) : 1; - modelDayMap.set(dayLabel, (modelDayMap.get(dayLabel) || 0) + increment); - labelsSet.add(dayLabel); - hasData = true; - }); - - const labels = Array.from(labelsSet).sort(); - const dataByModel = new Map(); - valuesByModel.forEach((dayMap, modelName) => { - const series = labels.map(label => dayMap.get(label) || 0); - dataByModel.set(modelName, series); - }); - - return { labels, dataByModel, hasData }; - } - - buildChartDataForMetric(period = 'day', metric = 'requests') { - const baseSeries = period === 'hour' - ? this.buildHourlySeriesByModel(metric) - : this.buildDailySeriesByModel(metric); - - const labels = baseSeries?.labels || []; - const dataByModel = baseSeries?.dataByModel || new Map(); - const activeSelections = this.getActiveChartLineSelections(); - const datasets = activeSelections.map(selection => { - const values = dataByModel.get(selection.model) || new Array(labels.length).fill(0); - const style = this.chartLineStyles[selection.index] || this.chartLineStyles[0]; - return { - label: selection.model, - data: values, - borderColor: style.borderColor, - backgroundColor: style.backgroundColor, - fill: false, - tension: 0.35, - pointBackgroundColor: style.borderColor, - pointBorderColor: '#ffffff', - pointBorderWidth: 2, - pointRadius: values.some(v => v > 0) ? 4 : 3 - }; - }); - - return { labels, datasets }; - } - - // 统一格式化小时标签 - formatHourLabel(date) { - if (!(date instanceof Date)) { - return ''; - } - const month = (date.getMonth() + 1).toString().padStart(2, '0'); - const day = date.getDate().toString().padStart(2, '0'); - const hour = date.getHours().toString().padStart(2, '0'); - return `${month}-${day} ${hour}:00`; - } - - formatDayLabel(date) { - if (!(date instanceof Date)) { - return ''; - } - const year = date.getFullYear(); - const month = (date.getMonth() + 1).toString().padStart(2, '0'); - const day = date.getDate().toString().padStart(2, '0'); - return `${year}-${month}-${day}`; - } - - extractTotalTokens(detail) { - const tokens = detail?.tokens || {}; - if (typeof tokens.total_tokens === 'number') { - return tokens.total_tokens; - } - const tokenKeys = ['input_tokens', 'output_tokens', 'reasoning_tokens', 'cached_tokens']; - return tokenKeys.reduce((sum, key) => { - const value = tokens[key]; - return sum + (typeof value === 'number' ? value : 0); - }, 0); - } - - // 初始化图表 - initializeCharts() { - const requestsHourActive = document.getElementById('requests-hour-btn')?.classList.contains('active'); - const tokensHourActive = document.getElementById('tokens-hour-btn')?.classList.contains('active'); - this.initializeRequestsChart(requestsHourActive ? 'hour' : 'day'); - this.initializeTokensChart(tokensHourActive ? 'hour' : 'day'); - } - - // 初始化请求趋势图表 - initializeRequestsChart(period = 'day') { - const ctx = document.getElementById('requests-chart'); - if (!ctx) return; - - // 销毁现有图表 - if (this.requestsChart) { - this.requestsChart.destroy(); - } - - const data = this.getRequestsChartData(period); - - this.requestsChart = new Chart(ctx, { - type: 'line', - data: data, - options: { - responsive: true, - maintainAspectRatio: false, - interaction: { - mode: 'index', - intersect: false - }, - plugins: { - legend: { - display: true, - position: 'top', - align: 'start', - labels: { - usePointStyle: true - } - } - }, - scales: { - x: { - title: { - display: true, - text: i18n.t(period === 'hour' ? 'usage_stats.by_hour' : 'usage_stats.by_day') - } - }, - y: { - beginAtZero: true, - title: { - display: true, - text: i18n.t('usage_stats.requests_count') - } - } - }, - elements: { - line: { - tension: 0.35, - borderWidth: 2 - }, - point: { - borderWidth: 2, - radius: 4 - } - } - } - }); - } - - // 初始化Token使用趋势图表 - initializeTokensChart(period = 'day') { - const ctx = document.getElementById('tokens-chart'); - if (!ctx) return; - - // 销毁现有图表 - if (this.tokensChart) { - this.tokensChart.destroy(); - } - - const data = this.getTokensChartData(period); - - this.tokensChart = new Chart(ctx, { - type: 'line', - data: data, - options: { - responsive: true, - maintainAspectRatio: false, - interaction: { - mode: 'index', - intersect: false - }, - plugins: { - legend: { - display: true, - position: 'top', - align: 'start', - labels: { - usePointStyle: true - } - } - }, - scales: { - x: { - title: { - display: true, - text: i18n.t(period === 'hour' ? 'usage_stats.by_hour' : 'usage_stats.by_day') - } - }, - y: { - beginAtZero: true, - title: { - display: true, - text: i18n.t('usage_stats.tokens_count') - } - } - }, - elements: { - line: { - tension: 0.35, - borderWidth: 2 - }, - point: { - borderWidth: 2, - radius: 4 - } - } - } - }); - } - - // 获取请求图表数据 - getRequestsChartData(period) { - if (!this.currentUsageData) { - return { labels: [], datasets: [] }; - } - return this.buildChartDataForMetric(period, 'requests'); - } - - // 获取Token图表数据 - getTokensChartData(period) { - if (!this.currentUsageData) { - return { labels: [], datasets: [] }; - } - return this.buildChartDataForMetric(period, 'tokens'); - } - - // 切换请求图表时间周期 - switchRequestsPeriod(period) { - // 更新按钮状态 - document.getElementById('requests-hour-btn').classList.toggle('active', period === 'hour'); - document.getElementById('requests-day-btn').classList.toggle('active', period === 'day'); - - // 更新图表数据 - if (this.requestsChart) { - const newData = this.getRequestsChartData(period); - this.requestsChart.data = newData; - this.requestsChart.options.scales.x.title.text = i18n.t(period === 'hour' ? 'usage_stats.by_hour' : 'usage_stats.by_day'); - this.requestsChart.update(); - } - } - - // 切换Token图表时间周期 - switchTokensPeriod(period) { - // 更新按钮状态 - document.getElementById('tokens-hour-btn').classList.toggle('active', period === 'hour'); - document.getElementById('tokens-day-btn').classList.toggle('active', period === 'day'); - - // 更新图表数据 - if (this.tokensChart) { - const newData = this.getTokensChartData(period); - this.tokensChart.data = newData; - this.tokensChart.options.scales.x.title.text = i18n.t(period === 'hour' ? 'usage_stats.by_hour' : 'usage_stats.by_day'); - this.tokensChart.update(); - } - } - - // 更新API详细统计表格 - updateApiStatsTable(data) { - const container = document.getElementById('api-stats-table'); - if (!container) return; - - const apis = data.apis || {}; - - if (Object.keys(apis).length === 0) { - container.innerHTML = `
${i18n.t('usage_stats.no_data')}
`; - return; - } - - let tableHtml = ` - - - - - - - - - - - - `; - - Object.entries(apis).forEach(([endpoint, apiData]) => { - const totalRequests = apiData.total_requests || 0; - const successCount = apiData.success_count ?? null; - const successRate = successCount !== null && totalRequests > 0 - ? Math.round((successCount / totalRequests) * 100) - : null; - - // 构建模型详情 - let modelsHtml = ''; - if (apiData.models && Object.keys(apiData.models).length > 0) { - modelsHtml = '
'; - Object.entries(apiData.models).forEach(([modelName, modelData]) => { - const modelRequests = modelData.total_requests ?? 0; - const modelTokens = modelData.total_tokens ?? 0; - modelsHtml += ` -
- ${modelName} - ${modelRequests} 请求 / ${modelTokens} tokens -
- `; - }); - modelsHtml += '
'; - } - - tableHtml += ` - - - - - - - - `; - }); - - tableHtml += '
${i18n.t('usage_stats.api_endpoint')}${i18n.t('usage_stats.requests_count')}${i18n.t('usage_stats.tokens_count')}${i18n.t('usage_stats.success_rate')}${i18n.t('usage_stats.models')}
${endpoint}${totalRequests}${apiData.total_tokens || 0}${successRate !== null ? successRate + '%' : '-'}${modelsHtml || '-'}
'; - container.innerHTML = tableHtml; - } + currentModelFilter = 'all'; showModal() { const modal = document.getElementById('modal'); @@ -6586,17 +1039,6 @@ class CLIProxyManager { } } - updateLoginConnectionInfo() { - const connectionUrlElement = document.getElementById('login-connection-url'); - const customInput = document.getElementById('login-api-base'); - if (connectionUrlElement) { - connectionUrlElement.textContent = this.apiBase || '-'; - } - if (customInput && customInput !== document.activeElement) { - customInput.value = this.apiBase || ''; - } - } - addModelField(wrapperId, model = {}) { const wrapper = document.getElementById(wrapperId); if (!wrapper) return; @@ -6692,6 +1134,22 @@ class CLIProxyManager { } } +Object.assign( + CLIProxyManager.prototype, + themeModule, + navigationModule, + languageModule, + loginModule, + configEditorModule, + logsModule, + apiKeysModule, + authFilesModule, + oauthModule, + usageModule, + settingsModule, + aiProvidersModule +); + // 全局管理器实例 let manager; diff --git a/build.js b/build.cjs similarity index 72% rename from build.js rename to build.cjs index 8453a65..2819b3e 100644 --- a/build.js +++ b/build.cjs @@ -6,12 +6,12 @@ const path = require('path'); const projectRoot = __dirname; const distDir = path.join(projectRoot, 'dist'); -const sourceFiles = { - html: path.join(projectRoot, 'index.html'), - css: path.join(projectRoot, 'styles.css'), - i18n: path.join(projectRoot, 'i18n.js'), - app: path.join(projectRoot, 'app.js') -}; +const sourceFiles = { + html: path.join(projectRoot, 'index.html'), + css: path.join(projectRoot, 'styles.css'), + i18n: path.join(projectRoot, 'i18n.js'), + app: path.join(projectRoot, 'app.js') +}; const logoCandidates = ['logo.png', 'logo.jpg', 'logo.jpeg', 'logo.svg', 'logo.webp', 'logo.gif']; const logoMimeMap = { @@ -78,12 +78,44 @@ function getVersion() { return 'v0.0.0-dev'; } -function ensureDistDir() { - if (fs.existsSync(distDir)) { - fs.rmSync(distDir, { recursive: true, force: true }); - } - fs.mkdirSync(distDir); -} +function ensureDistDir() { + if (fs.existsSync(distDir)) { + fs.rmSync(distDir, { recursive: true, force: true }); + } + fs.mkdirSync(distDir); +} + +const importRegex = /^import\s+.+?from\s+['"](.+?)['"];?$/gm; +const exportRegex = /^export\s+/gm; + +function bundleApp(entryPath) { + const visited = new Set(); + + function inlineFile(filePath) { + let content = readFile(filePath); + const dir = path.dirname(filePath); + + content = content.replace(importRegex, (match, specifier) => { + const targetPath = path.resolve(dir, specifier); + const normalized = path.normalize(targetPath); + if (!fs.existsSync(normalized)) { + throw new Error(`无法解析模块: ${specifier} (from ${filePath})`); + } + if (visited.has(normalized)) { + return ''; + } + visited.add(normalized); + let moduleContent = inlineFile(normalized); + moduleContent = moduleContent.replace(exportRegex, ''); + const relativePath = path.relative(projectRoot, normalized); + return `\n// ${relativePath}\n${moduleContent}\n`; + }); + + return content; + } + + return inlineFile(entryPath); +} function loadLogoDataUrl() { for (const candidate of logoCandidates) { @@ -110,7 +142,8 @@ function build() { let html = readFile(sourceFiles.html); const css = escapeForStyle(readFile(sourceFiles.css)); const i18n = escapeForScript(readFile(sourceFiles.i18n)); - const app = escapeForScript(readFile(sourceFiles.app)); + const bundledApp = bundleApp(sourceFiles.app); + const app = escapeForScript(bundledApp); // 获取版本号并替换 const version = getVersion(); @@ -131,12 +164,17 @@ ${i18n} ` ); - html = html.replace( - '', - `` - ); + const scriptTagRegex = /]*src="app\.js"[^>]*><\/script>/i; + if (scriptTagRegex.test(html)) { + html = html.replace( + scriptTagRegex, + `` + ); + } else { + console.warn('未找到 app.js 脚本标签,未内联应用代码。'); + } const logoDataUrl = loadLogoDataUrl(); if (logoDataUrl) { diff --git a/index.html b/index.html index 313584a..dda0222 100644 --- a/index.html +++ b/index.html @@ -1028,7 +1028,7 @@ - + diff --git a/package.json b/package.json index 8263ee0..63cbb4c 100644 --- a/package.json +++ b/package.json @@ -3,10 +3,11 @@ "version": "1.0.0", "description": "CLI Proxy API 管理界面", "main": "index.html", + "type": "module", "scripts": { "start": "npx serve .", "dev": "npx serve . --port 3000", - "build": "node build.js", + "build": "node build.cjs", "lint": "echo '使用浏览器开发者工具检查代码'" }, "keywords": [ diff --git a/src/modules/ai-providers.js b/src/modules/ai-providers.js new file mode 100644 index 0000000..1dfb8d6 --- /dev/null +++ b/src/modules/ai-providers.js @@ -0,0 +1,1293 @@ +// AI 提供商配置相关方法模块(当前只抽取 Gemini 相关逻辑) +// 这些函数依赖于 CLIProxyManager 实例上的 makeRequest/getConfig/clearCache/showNotification 等能力, +// 以及 apiKeysModule 中的工具方法(如 applyHeadersToConfig/renderHeaderBadges)。 + +export async function loadGeminiKeys() { + try { + const config = await this.getConfig(); + const keys = this.getGeminiKeysFromConfig(config); + await this.renderGeminiKeys(keys); + } catch (error) { + console.error('加载Gemini密钥失败:', error); + } +} + +export function getGeminiKeysFromConfig(config) { + if (!config) { + return []; + } + + const geminiKeys = Array.isArray(config['gemini-api-key']) ? config['gemini-api-key'] : []; + if (geminiKeys.length > 0) { + return geminiKeys; + } + + const legacyKeys = Array.isArray(config['generative-language-api-key']) ? config['generative-language-api-key'] : []; + return legacyKeys + .map(item => { + if (item && typeof item === 'object') { + return { ...item }; + } + if (typeof item === 'string') { + const trimmed = item.trim(); + if (trimmed) { + return { 'api-key': trimmed }; + } + } + return null; + }) + .filter(Boolean); +} + +export async function renderGeminiKeys(keys, keyStats = null) { + const container = document.getElementById('gemini-keys-list'); + if (!container) { + return; + } + const normalizedList = (Array.isArray(keys) ? keys : []).map(item => { + let normalized = null; + if (item && typeof item === 'object') { + normalized = { ...item }; + } else if (typeof item === 'string') { + const trimmed = item.trim(); + if (trimmed) { + normalized = { 'api-key': trimmed }; + } + } + + if (normalized && !normalized['base-url'] && normalized['base_url']) { + normalized['base-url'] = normalized['base_url']; + } + + return normalized; + }).filter(config => config && config['api-key']); + this.cachedGeminiKeys = normalizedList; + + if (normalizedList.length === 0) { + container.innerHTML = ` +
+ +

${i18n.t('ai_providers.gemini_empty_title')}

+

${i18n.t('ai_providers.gemini_empty_desc')}

+
+ `; + return; + } + + if (!keyStats) { + keyStats = await this.getKeyStats(); + } + const stats = keyStats; + + container.innerHTML = normalizedList.map((config, index) => { + const rawKey = config['api-key'] || ''; + const masked = this.maskApiKey(rawKey || ''); + const maskedDisplay = this.escapeHtml(masked); + const keyStats = (rawKey && (stats[rawKey] || stats[masked])) || { success: 0, failure: 0 }; + const configJson = JSON.stringify(config).replace(/"/g, '"'); + const apiKeyJson = JSON.stringify(rawKey || '').replace(/"/g, '"'); + const baseUrl = config['base-url'] || config['base_url'] || ''; + return ` +
+
+
${i18n.t('ai_providers.gemini_item_title')} #${index + 1}
+
${maskedDisplay}
+ ${baseUrl ? `
${i18n.t('common.base_url')}: ${this.escapeHtml(baseUrl)}
` : ''} + ${this.renderHeaderBadges(config.headers)} +
+ + ${i18n.t('stats.success')}: ${keyStats.success} + + + ${i18n.t('stats.failure')}: ${keyStats.failure} + +
+
+
+ + +
+
+ `; + }).join(''); +} + +export function showAddGeminiKeyModal() { + const modal = document.getElementById('modal'); + const modalBody = document.getElementById('modal-body'); + + modalBody.innerHTML = ` +

${i18n.t('ai_providers.gemini_add_modal_title')}

+
+ +

${i18n.t('ai_providers.gemini_add_modal_key_hint')}

+
+ +
+
+ +

${i18n.t('common.custom_headers_hint')}

+
+ +
+ + `; + + modal.style.display = 'block'; + this.populateGeminiKeyFields('new-gemini-keys-wrapper'); + this.populateHeaderFields('new-gemini-headers-wrapper'); +} + +export async function addGeminiKey() { + const entries = this.collectGeminiKeyFieldInputs('new-gemini-keys-wrapper'); + const headers = this.collectHeaderInputs('new-gemini-headers-wrapper'); + + if (!entries.length) { + this.showNotification(i18n.t('notification.gemini_multi_input_required'), 'error'); + return; + } + + try { + const data = await this.makeRequest('/gemini-api-key'); + let currentKeys = Array.isArray(data['gemini-api-key']) ? data['gemini-api-key'] : []; + const existingKeys = new Set(currentKeys.map(item => item && item['api-key']).filter(Boolean)); + const batchSeen = new Set(); + + let successCount = 0; + let skippedCount = 0; + let failedCount = 0; + + for (const entry of entries) { + const apiKey = entry['api-key']; + if (!apiKey) { + continue; + } + + if (batchSeen.has(apiKey)) { + skippedCount++; + continue; + } + batchSeen.add(apiKey); + + if (existingKeys.has(apiKey)) { + skippedCount++; + continue; + } + + const newConfig = { 'api-key': apiKey }; + const baseUrl = entry['base-url']; + if (baseUrl) { + newConfig['base-url'] = baseUrl; + } else { + delete newConfig['base-url']; + } + this.applyHeadersToConfig(newConfig, headers); + + const nextKeys = [...currentKeys, newConfig]; + + try { + await this.makeRequest('/gemini-api-key', { + method: 'PUT', + body: JSON.stringify(nextKeys) + }); + currentKeys = nextKeys; + existingKeys.add(apiKey); + successCount++; + } catch (error) { + failedCount++; + console.error('Gemini key add failed:', error); + } + } + + this.clearCache(); // 清除缓存 + this.closeModal(); + this.loadGeminiKeys(); + + if (successCount === 1 && skippedCount === 0 && failedCount === 0) { + this.showNotification(i18n.t('notification.gemini_key_added'), 'success'); + return; + } + + const summaryTemplate = i18n.t('notification.gemini_multi_summary'); + const summary = summaryTemplate + .replace('{success}', successCount) + .replace('{skipped}', skippedCount) + .replace('{failed}', failedCount); + const status = failedCount > 0 ? 'warning' : (successCount > 0 ? 'success' : 'info'); + this.showNotification(summary, status); + } catch (error) { + this.showNotification(`${i18n.t('notification.gemini_multi_failed')}: ${error.message}`, 'error'); + } +} + +export function addGeminiKeyField(wrapperId, entry = {}, options = {}) { + const wrapper = document.getElementById(wrapperId); + if (!wrapper) return; + + const row = document.createElement('div'); + row.className = 'api-key-input-row'; + const apiKeyValue = typeof entry?.['api-key'] === 'string' ? entry['api-key'] : ''; + const baseUrlValue = typeof entry?.['base-url'] === 'string' + ? entry['base-url'] + : (typeof entry?.['base_url'] === 'string' ? entry['base_url'] : ''); + const allowRemoval = options.allowRemoval !== false; + const removeButtonHtml = allowRemoval + ? `` + : ''; + row.innerHTML = ` +
+ + + ${removeButtonHtml} +
+ `; + + if (allowRemoval) { + const removeBtn = row.querySelector('.gemini-key-remove-btn'); + if (removeBtn) { + removeBtn.addEventListener('click', () => { + wrapper.removeChild(row); + if (wrapper.childElementCount === 0) { + this.addGeminiKeyField(wrapperId, {}, options); + } + }); + } + } + + wrapper.appendChild(row); +} + +export function populateGeminiKeyFields(wrapperId, entries = [], options = {}) { + const wrapper = document.getElementById(wrapperId); + if (!wrapper) return; + wrapper.innerHTML = ''; + + if (!Array.isArray(entries) || entries.length === 0) { + this.addGeminiKeyField(wrapperId, {}, options); + return; + } + + entries.forEach(entry => this.addGeminiKeyField(wrapperId, entry, options)); +} + +export function collectGeminiKeyFieldInputs(wrapperId) { + const wrapper = document.getElementById(wrapperId); + if (!wrapper) return []; + + const rows = Array.from(wrapper.querySelectorAll('.api-key-input-row')); + const entries = []; + + rows.forEach(row => { + const keyInput = row.querySelector('.api-key-value-input'); + const urlInput = row.querySelector('.api-key-proxy-input'); + const apiKey = keyInput ? keyInput.value.trim() : ''; + const baseUrl = urlInput ? urlInput.value.trim() : ''; + if (apiKey) { + entries.push({ 'api-key': apiKey, 'base-url': baseUrl }); + } + }); + + return entries; +} + +export function editGeminiKey(index, config) { + const modal = document.getElementById('modal'); + const modalBody = document.getElementById('modal-body'); + this.currentGeminiEditConfig = config || {}; + + modalBody.innerHTML = ` +

${i18n.t('ai_providers.gemini_edit_modal_title')}

+
+ +
+
+
+ +

${i18n.t('common.custom_headers_hint')}

+
+ +
+ + `; + + modal.style.display = 'block'; + this.populateGeminiKeyFields('edit-gemini-keys-wrapper', [config], { allowRemoval: false }); + this.populateHeaderFields('edit-gemini-headers-wrapper', config.headers || null); +} + +export async function updateGeminiKey(index) { + const entries = this.collectGeminiKeyFieldInputs('edit-gemini-keys-wrapper'); + if (!entries.length) { + this.showNotification(i18n.t('notification.please_enter') + ' ' + i18n.t('notification.gemini_api_key'), 'error'); + return; + } + const entry = entries[0]; + const newKey = entry['api-key']; + const baseUrl = entry['base-url'] || ''; + const headers = this.collectHeaderInputs('edit-gemini-headers-wrapper'); + + if (!newKey) { + this.showNotification(i18n.t('notification.please_enter') + ' ' + i18n.t('notification.gemini_api_key'), 'error'); + return; + } + + try { + const existingConfig = (this.cachedGeminiKeys && this.cachedGeminiKeys[index]) || this.currentGeminiEditConfig || {}; + const newConfig = { ...existingConfig, 'api-key': newKey }; + if (baseUrl) { + newConfig['base-url'] = baseUrl; + } else { + delete newConfig['base-url']; + } + this.applyHeadersToConfig(newConfig, headers); + + await this.makeRequest('/gemini-api-key', { + method: 'PATCH', + body: JSON.stringify({ index, value: newConfig }) + }); + + this.clearCache(); // 清除缓存 + this.closeModal(); + this.loadGeminiKeys(); + this.currentGeminiEditConfig = null; + this.showNotification(i18n.t('notification.gemini_key_updated'), 'success'); + } catch (error) { + this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error'); + } +} + +export async function deleteGeminiKey(apiKey) { + if (!confirm(i18n.t('ai_providers.gemini_delete_confirm'))) return; + + try { + await this.makeRequest(`/gemini-api-key?api-key=${encodeURIComponent(apiKey)}`, { method: 'DELETE' }); + this.clearCache(); // 清除缓存 + this.loadGeminiKeys(); + this.showNotification(i18n.t('notification.gemini_key_deleted'), 'success'); + } catch (error) { + this.showNotification(`${i18n.t('notification.delete_failed')}: ${error.message}`, 'error'); + } +} + +// Codex providers +export async function loadCodexKeys() { + try { + const config = await this.getConfig(); + const keys = Array.isArray(config['codex-api-key']) ? config['codex-api-key'] : []; + await this.renderCodexKeys(keys); + } catch (error) { + console.error('加载Codex密钥失败:', error); + } +} + +export async function renderCodexKeys(keys, keyStats = null) { + const container = document.getElementById('codex-keys-list'); + if (!container) { + return; + } + const list = Array.isArray(keys) ? keys : []; + + if (list.length === 0) { + container.innerHTML = ` +
+ +

${i18n.t('ai_providers.codex_empty_title')}

+

${i18n.t('ai_providers.codex_empty_desc')}

+
+ `; + return; + } + + // 使用传入的keyStats,如果没有则获取一次 + if (!keyStats) { + keyStats = await this.getKeyStats(); + } + const stats = keyStats; + + container.innerHTML = list.map((config, index) => { + const rawKey = config['api-key'] || ''; + const masked = this.maskApiKey(rawKey || ''); + const maskedDisplay = this.escapeHtml(masked); + const keyStats = (rawKey && (stats[rawKey] || stats[masked])) || { success: 0, failure: 0 }; + const deleteArg = JSON.stringify(rawKey).replace(/"/g, '"'); + return ` +
+
+
${i18n.t('ai_providers.codex_item_title')} #${index + 1}
+
${i18n.t('common.api_key')}: ${maskedDisplay}
+ ${config['base-url'] ? `
${i18n.t('common.base_url')}: ${this.escapeHtml(config['base-url'])}
` : ''} + ${config['proxy-url'] ? `
${i18n.t('common.proxy_url')}: ${this.escapeHtml(config['proxy-url'])}
` : ''} + ${this.renderHeaderBadges(config.headers)} +
+ + ${i18n.t('stats.success')}: ${keyStats.success} + + + ${i18n.t('stats.failure')}: ${keyStats.failure} + +
+
+
+ + +
+
+ `; + }).join(''); +} + +export function showAddCodexKeyModal() { + const modal = document.getElementById('modal'); + const modalBody = document.getElementById('modal-body'); + + modalBody.innerHTML = ` +

${i18n.t('ai_providers.codex_add_modal_title')}

+
+ + +
+
+ + +
+
+ + +
+
+ +

${i18n.t('common.custom_headers_hint')}

+
+ +
+ + `; + + modal.style.display = 'block'; + this.populateHeaderFields('new-codex-headers-wrapper'); +} + +export async function addCodexKey() { + const apiKey = document.getElementById('new-codex-key').value.trim(); + const baseUrl = document.getElementById('new-codex-url').value.trim(); + const proxyUrl = document.getElementById('new-codex-proxy').value.trim(); + const headers = this.collectHeaderInputs('new-codex-headers-wrapper'); + + if (!apiKey) { + this.showNotification(i18n.t('notification.field_required'), 'error'); + return; + } + if (!baseUrl) { + this.showNotification(i18n.t('notification.codex_base_url_required'), 'error'); + return; + } + + try { + const data = await this.makeRequest('/codex-api-key'); + const currentKeys = this.normalizeArrayResponse(data, 'codex-api-key').map(item => ({ ...item })); + + const newConfig = this.buildCodexConfig(apiKey, baseUrl, proxyUrl, {}, headers); + + currentKeys.push(newConfig); + + await this.makeRequest('/codex-api-key', { + method: 'PUT', + body: JSON.stringify(currentKeys) + }); + + this.clearCache(); // 清除缓存 + this.closeModal(); + this.loadCodexKeys(); + this.showNotification(i18n.t('notification.codex_config_added'), 'success'); + } catch (error) { + this.showNotification(`${i18n.t('notification.add_failed')}: ${error.message}`, 'error'); + } +} + +export function editCodexKey(index, config) { + const modal = document.getElementById('modal'); + const modalBody = document.getElementById('modal-body'); + + modalBody.innerHTML = ` +

${i18n.t('ai_providers.codex_edit_modal_title')}

+
+ + +
+
+ + +
+
+ + +
+
+ +

${i18n.t('common.custom_headers_hint')}

+
+ +
+ + `; + + modal.style.display = 'block'; + this.populateHeaderFields('edit-codex-headers-wrapper', config.headers || null); +} + +export async function updateCodexKey(index) { + const apiKey = document.getElementById('edit-codex-key').value.trim(); + const baseUrl = document.getElementById('edit-codex-url').value.trim(); + const proxyUrl = document.getElementById('edit-codex-proxy').value.trim(); + const headers = this.collectHeaderInputs('edit-codex-headers-wrapper'); + + if (!apiKey) { + this.showNotification(i18n.t('notification.field_required'), 'error'); + return; + } + if (!baseUrl) { + this.showNotification(i18n.t('notification.codex_base_url_required'), 'error'); + return; + } + + try { + const listResponse = await this.makeRequest('/codex-api-key'); + const currentList = this.normalizeArrayResponse(listResponse, 'codex-api-key'); + + if (!Array.isArray(currentList) || index < 0 || index >= currentList.length) { + throw new Error('Invalid codex configuration index'); + } + + const original = currentList[index] ? { ...currentList[index] } : {}; + const newConfig = this.buildCodexConfig(apiKey, baseUrl, proxyUrl, original, headers); + + await this.makeRequest('/codex-api-key', { + method: 'PATCH', + body: JSON.stringify({ index, value: newConfig }) + }); + + this.clearCache(); // 清除缓存 + this.closeModal(); + this.loadCodexKeys(); + this.showNotification(i18n.t('notification.codex_config_updated'), 'success'); + } catch (error) { + this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error'); + } +} + +export async function deleteCodexKey(apiKey) { + if (!confirm(i18n.t('ai_providers.codex_delete_confirm'))) return; + + try { + await this.makeRequest(`/codex-api-key?api-key=${encodeURIComponent(apiKey)}`, { method: 'DELETE' }); + this.clearCache(); // 清除缓存 + this.loadCodexKeys(); + this.showNotification(i18n.t('notification.codex_config_deleted'), 'success'); + } catch (error) { + this.showNotification(`${i18n.t('notification.delete_failed')}: ${error.message}`, 'error'); + } +} + +// Claude providers +export async function loadClaudeKeys() { + try { + const config = await this.getConfig(); + const keys = Array.isArray(config['claude-api-key']) ? config['claude-api-key'] : []; + await this.renderClaudeKeys(keys); + } catch (error) { + console.error('加载Claude密钥失败:', error); + } +} + +export async function renderClaudeKeys(keys, keyStats = null) { + const container = document.getElementById('claude-keys-list'); + if (!container) { + return; + } + const list = Array.isArray(keys) ? keys : []; + + if (list.length === 0) { + container.innerHTML = ` +
+ +

${i18n.t('ai_providers.claude_empty_title')}

+

${i18n.t('ai_providers.claude_empty_desc')}

+
+ `; + return; + } + + // 使用传入的keyStats,如果没有则获取一次 + if (!keyStats) { + keyStats = await this.getKeyStats(); + } + const stats = keyStats; + + container.innerHTML = list.map((config, index) => { + const rawKey = config['api-key'] || ''; + const masked = this.maskApiKey(rawKey || ''); + const maskedDisplay = this.escapeHtml(masked); + const keyStats = (rawKey && (stats[rawKey] || stats[masked])) || { success: 0, failure: 0 }; + const deleteArg = JSON.stringify(rawKey).replace(/"/g, '"'); + const models = Array.isArray(config.models) ? config.models : []; + const modelsCountHtml = models.length + ? `
${i18n.t('ai_providers.claude_models_count')}: ${models.length}
` + : ''; + const modelsBadgesHtml = this.renderModelBadges(models); + return ` +
+
+
${i18n.t('ai_providers.claude_item_title')} #${index + 1}
+
${i18n.t('common.api_key')}: ${maskedDisplay}
+ ${config['base-url'] ? `
${i18n.t('common.base_url')}: ${this.escapeHtml(config['base-url'])}
` : ''} + ${config['proxy-url'] ? `
${i18n.t('common.proxy_url')}: ${this.escapeHtml(config['proxy-url'])}
` : ''} + ${this.renderHeaderBadges(config.headers)} + ${modelsCountHtml} + ${modelsBadgesHtml} +
+ + ${i18n.t('stats.success')}: ${keyStats.success} + + + ${i18n.t('stats.failure')}: ${keyStats.failure} + +
+
+
+ + +
+
+ `; + }).join(''); +} + +export function showAddClaudeKeyModal() { + const modal = document.getElementById('modal'); + const modalBody = document.getElementById('modal-body'); + + modalBody.innerHTML = ` +

${i18n.t('ai_providers.claude_add_modal_title')}

+
+ + +
+
+ + +
+
+ + +
+
+ +

${i18n.t('common.custom_headers_hint')}

+
+ +
+
+ +

${i18n.t('ai_providers.claude_models_hint')}

+
+ +
+ + `; + + modal.style.display = 'block'; + this.populateHeaderFields('new-claude-headers-wrapper'); + this.populateModelFields('new-claude-models-wrapper'); +} + +export async function addClaudeKey() { + const apiKey = document.getElementById('new-claude-key').value.trim(); + const baseUrl = document.getElementById('new-claude-url').value.trim(); + const proxyUrl = document.getElementById('new-claude-proxy').value.trim(); + const headers = this.collectHeaderInputs('new-claude-headers-wrapper'); + const models = this.collectModelInputs('new-claude-models-wrapper'); + + if (!apiKey) { + this.showNotification(i18n.t('notification.field_required'), 'error'); + return; + } + + try { + const data = await this.makeRequest('/claude-api-key'); + const currentKeys = data['claude-api-key'] || []; + + const newConfig = { 'api-key': apiKey }; + if (baseUrl) { + newConfig['base-url'] = baseUrl; + } + if (proxyUrl) { + newConfig['proxy-url'] = proxyUrl; + } + this.applyHeadersToConfig(newConfig, headers); + if (models.length) { + newConfig.models = models; + } + + currentKeys.push(newConfig); + + await this.makeRequest('/claude-api-key', { + method: 'PUT', + body: JSON.stringify(currentKeys) + }); + + this.clearCache(); // 清除缓存 + this.closeModal(); + this.loadClaudeKeys(); + this.showNotification(i18n.t('notification.claude_config_added'), 'success'); + } catch (error) { + this.showNotification(`${i18n.t('notification.add_failed')}: ${error.message}`, 'error'); + } +} + +export function editClaudeKey(index, config) { + const modal = document.getElementById('modal'); + const modalBody = document.getElementById('modal-body'); + + modalBody.innerHTML = ` +

${i18n.t('ai_providers.claude_edit_modal_title')}

+
+ + +
+
+ + +
+
+ + +
+
+ +

${i18n.t('common.custom_headers_hint')}

+
+ +
+
+ +

${i18n.t('ai_providers.claude_models_hint')}

+
+ +
+ + `; + + modal.style.display = 'block'; + this.populateHeaderFields('edit-claude-headers-wrapper', config.headers || null); + this.populateModelFields('edit-claude-models-wrapper', Array.isArray(config.models) ? config.models : []); +} + +export async function updateClaudeKey(index) { + const apiKey = document.getElementById('edit-claude-key').value.trim(); + const baseUrl = document.getElementById('edit-claude-url').value.trim(); + const proxyUrl = document.getElementById('edit-claude-proxy').value.trim(); + const headers = this.collectHeaderInputs('edit-claude-headers-wrapper'); + const models = this.collectModelInputs('edit-claude-models-wrapper'); + + if (!apiKey) { + this.showNotification(i18n.t('notification.field_required'), 'error'); + return; + } + + try { + const newConfig = { 'api-key': apiKey }; + if (baseUrl) { + newConfig['base-url'] = baseUrl; + } + if (proxyUrl) { + newConfig['proxy-url'] = proxyUrl; + } + this.applyHeadersToConfig(newConfig, headers); + if (models.length) { + newConfig.models = models; + } + + await this.makeRequest('/claude-api-key', { + method: 'PATCH', + body: JSON.stringify({ index, value: newConfig }) + }); + + this.clearCache(); // 清除缓存 + this.closeModal(); + this.loadClaudeKeys(); + this.showNotification(i18n.t('notification.claude_config_updated'), 'success'); + } catch (error) { + this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error'); + } +} + +export async function deleteClaudeKey(apiKey) { + if (!confirm(i18n.t('ai_providers.claude_delete_confirm'))) return; + + try { + await this.makeRequest(`/claude-api-key?api-key=${encodeURIComponent(apiKey)}`, { method: 'DELETE' }); + this.clearCache(); // 清除缓存 + this.loadClaudeKeys(); + this.showNotification(i18n.t('notification.claude_config_deleted'), 'success'); + } catch (error) { + this.showNotification(`${i18n.t('notification.delete_failed')}: ${error.message}`, 'error'); + } +} + +// OpenAI compatible providers +export async function loadOpenAIProviders() { + try { + const config = await this.getConfig(); + const providers = Array.isArray(config['openai-compatibility']) ? config['openai-compatibility'] : []; + await this.renderOpenAIProviders(providers); + } catch (error) { + console.error('加载OpenAI提供商失败:', error); + } +} + +export async function renderOpenAIProviders(providers, keyStats = null) { + const container = document.getElementById('openai-providers-list'); + if (!container) { + return; + } + const list = Array.isArray(providers) ? providers : []; + + if (list.length === 0) { + container.innerHTML = ` +
+ +

${i18n.t('ai_providers.openai_empty_title')}

+

${i18n.t('ai_providers.openai_empty_desc')}

+
+ `; + // 重置样式 + container.style.maxHeight = ''; + container.style.overflowY = ''; + return; + } + + // 根据提供商数量设置滚动条 + if (list.length > 5) { + container.style.maxHeight = '400px'; + container.style.overflowY = 'auto'; + } else { + container.style.maxHeight = ''; + container.style.overflowY = ''; + } + + // 使用传入的keyStats,如果没有则获取一次 + if (!keyStats) { + keyStats = await this.getKeyStats(); + } + const stats = keyStats; + + container.innerHTML = list.map((provider, index) => { + 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 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; + }); + + const deleteArg = JSON.stringify(name).replace(/"/g, '"'); + return ` +
+
+
${this.escapeHtml(name)}
+
${i18n.t('common.base_url')}: ${this.escapeHtml(baseUrl)}
+ ${this.renderHeaderBadges(item.headers)} +
${i18n.t('ai_providers.openai_keys_count')}: ${apiKeyEntries.length}
+
${i18n.t('ai_providers.openai_models_count')}: ${models.length}
+ ${this.renderModelBadges(models)} +
+ + ${i18n.t('stats.success')}: ${totalSuccess} + + + ${i18n.t('stats.failure')}: ${totalFailure} + +
+
+
+ + +
+
+ `; + }).join(''); +} + +export function showAddOpenAIProviderModal() { + const modal = document.getElementById('modal'); + const modalBody = document.getElementById('modal-body'); + + modalBody.innerHTML = ` +

${i18n.t('ai_providers.openai_add_modal_title')}

+
+ + +
+
+ + +
+
+ +

${i18n.t('ai_providers.openai_keys_hint')}

+
+ +
+
+ +

${i18n.t('common.custom_headers_hint')}

+
+ +
+
+ +

${i18n.t('ai_providers.openai_models_hint')}

+
+ +
+ + `; + + modal.style.display = 'block'; + this.populateModelFields('new-provider-models-wrapper', []); + this.populateHeaderFields('new-openai-headers-wrapper'); + this.populateApiKeyEntryFields('new-openai-keys-wrapper'); +} + +export async function addOpenAIProvider() { + const name = document.getElementById('new-provider-name').value.trim(); + const baseUrl = document.getElementById('new-provider-url').value.trim(); + const apiKeyEntries = this.collectApiKeyEntryInputs('new-openai-keys-wrapper'); + const models = this.collectModelInputs('new-provider-models-wrapper'); + const headers = this.collectHeaderInputs('new-openai-headers-wrapper'); + + if (!this.validateOpenAIProviderInput(name, baseUrl, models)) { + return; + } + + try { + const data = await this.makeRequest('/openai-compatibility'); + const currentProviders = data['openai-compatibility'] || []; + + const newProvider = { + name, + 'base-url': baseUrl, + 'api-key-entries': apiKeyEntries, + models + }; + this.applyHeadersToConfig(newProvider, headers); + + currentProviders.push(newProvider); + + await this.makeRequest('/openai-compatibility', { + method: 'PUT', + body: JSON.stringify(currentProviders) + }); + + this.clearCache(); // 清除缓存 + this.closeModal(); + this.loadOpenAIProviders(); + this.showNotification(i18n.t('notification.openai_provider_added'), 'success'); + } catch (error) { + this.showNotification(`${i18n.t('notification.add_failed')}: ${error.message}`, 'error'); + } +} + +export function editOpenAIProvider(index, provider) { + const modal = document.getElementById('modal'); + const modalBody = document.getElementById('modal-body'); + + // 处理两种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 models = Array.isArray(provider?.models) ? provider.models : []; + + modalBody.innerHTML = ` +

${i18n.t('ai_providers.openai_edit_modal_title')}

+
+ + +
+
+ + +
+
+ +

${i18n.t('ai_providers.openai_keys_hint')}

+
+ +
+
+ +

${i18n.t('common.custom_headers_hint')}

+
+ +
+
+ +

${i18n.t('ai_providers.openai_models_hint')}

+
+ +
+ + `; + + modal.style.display = 'block'; + this.populateModelFields('edit-provider-models-wrapper', models); + this.populateHeaderFields('edit-openai-headers-wrapper', provider?.headers || null); + this.populateApiKeyEntryFields('edit-openai-keys-wrapper', apiKeyEntries); +} + +export async function updateOpenAIProvider(index) { + const name = document.getElementById('edit-provider-name').value.trim(); + const baseUrl = document.getElementById('edit-provider-url').value.trim(); + const apiKeyEntries = this.collectApiKeyEntryInputs('edit-openai-keys-wrapper'); + const models = this.collectModelInputs('edit-provider-models-wrapper'); + const headers = this.collectHeaderInputs('edit-openai-headers-wrapper'); + + if (!this.validateOpenAIProviderInput(name, baseUrl, models)) { + return; + } + + try { + const updatedProvider = { + name, + 'base-url': baseUrl, + 'api-key-entries': apiKeyEntries, + models + }; + this.applyHeadersToConfig(updatedProvider, headers); + + await this.makeRequest('/openai-compatibility', { + method: 'PATCH', + body: JSON.stringify({ index, value: updatedProvider }) + }); + + this.clearCache(); // 清除缓存 + this.closeModal(); + this.loadOpenAIProviders(); + this.showNotification(i18n.t('notification.openai_provider_updated'), 'success'); + } catch (error) { + this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error'); + } +} + +export async function deleteOpenAIProvider(name) { + if (!confirm(i18n.t('ai_providers.openai_delete_confirm'))) return; + + try { + await this.makeRequest(`/openai-compatibility?name=${encodeURIComponent(name)}`, { method: 'DELETE' }); + this.clearCache(); // 清除缓存 + this.loadOpenAIProviders(); + this.showNotification(i18n.t('notification.openai_provider_deleted'), 'success'); + } catch (error) { + this.showNotification(`${i18n.t('notification.delete_failed')}: ${error.message}`, 'error'); + } +} + +export function addModelField(wrapperId, model = {}) { + const wrapper = document.getElementById(wrapperId); + if (!wrapper) return; + + const row = document.createElement('div'); + row.className = 'model-input-row'; + row.innerHTML = ` +
+ + + +
+ `; + + const removeBtn = row.querySelector('.model-remove-btn'); + if (removeBtn) { + removeBtn.addEventListener('click', () => { + wrapper.removeChild(row); + }); + } + + wrapper.appendChild(row); +} + +export function populateModelFields(wrapperId, models = []) { + const wrapper = document.getElementById(wrapperId); + if (!wrapper) return; + wrapper.innerHTML = ''; + + if (!models.length) { + this.addModelField(wrapperId); + return; + } + + models.forEach(model => this.addModelField(wrapperId, model)); +} + +export function collectModelInputs(wrapperId) { + const wrapper = document.getElementById(wrapperId); + if (!wrapper) return []; + + const rows = Array.from(wrapper.querySelectorAll('.model-input-row')); + const models = []; + + rows.forEach(row => { + const nameInput = row.querySelector('.model-name-input'); + const aliasInput = row.querySelector('.model-alias-input'); + const name = nameInput ? nameInput.value.trim() : ''; + const alias = aliasInput ? aliasInput.value.trim() : ''; + + if (name) { + const model = { name }; + if (alias) { + model.alias = alias; + } + models.push(model); + } + }); + + return models; +} + +export function renderModelBadges(models) { + if (!models || models.length === 0) { + return ''; + } + + return ` +
+ ${models.map(model => ` + + ${this.escapeHtml(model.name || '')} + ${model.alias ? `${this.escapeHtml(model.alias)}` : ''} + + `).join('')} +
+ `; +} + +export function validateOpenAIProviderInput(name, baseUrl, models) { + if (!name || !baseUrl) { + this.showNotification(i18n.t('notification.openai_provider_required'), 'error'); + return false; + } + + const invalidModel = models.find(model => !model.name); + if (invalidModel) { + this.showNotification(i18n.t('notification.openai_model_name_required'), 'error'); + return false; + } + + return true; +} + +export const aiProvidersModule = { + loadGeminiKeys, + getGeminiKeysFromConfig, + renderGeminiKeys, + showAddGeminiKeyModal, + addGeminiKey, + addGeminiKeyField, + populateGeminiKeyFields, + collectGeminiKeyFieldInputs, + editGeminiKey, + updateGeminiKey, + deleteGeminiKey, + loadCodexKeys, + renderCodexKeys, + showAddCodexKeyModal, + addCodexKey, + editCodexKey, + updateCodexKey, + deleteCodexKey, + loadClaudeKeys, + renderClaudeKeys, + showAddClaudeKeyModal, + addClaudeKey, + editClaudeKey, + updateClaudeKey, + deleteClaudeKey, + loadOpenAIProviders, + renderOpenAIProviders, + showAddOpenAIProviderModal, + addOpenAIProvider, + editOpenAIProvider, + updateOpenAIProvider, + deleteOpenAIProvider, + addModelField, + populateModelFields, + collectModelInputs, + renderModelBadges, + validateOpenAIProviderInput +}; diff --git a/src/modules/api-keys.js b/src/modules/api-keys.js new file mode 100644 index 0000000..0e6bc6e --- /dev/null +++ b/src/modules/api-keys.js @@ -0,0 +1,273 @@ +export const apiKeysModule = { + // 加载API密钥 + async loadApiKeys() { + try { + const config = await this.getConfig(); + if (config['api-keys']) { + this.renderApiKeys(config['api-keys']); + } + } catch (error) { + console.error('加载API密钥失败:', error); + } + }, + + // 渲染API密钥列表 + renderApiKeys(keys) { + const container = document.getElementById('api-keys-list'); + + if (keys.length === 0) { + container.innerHTML = ` +
+ +

${i18n.t('api_keys.empty_title')}

+

${i18n.t('api_keys.empty_desc')}

+
+ `; + return; + } + + container.innerHTML = keys.map((key, index) => { + const normalizedKey = typeof key === 'string' ? key : String(key ?? ''); + const maskedDisplay = this.escapeHtml(this.maskApiKey(normalizedKey)); + const keyArgument = JSON.stringify(normalizedKey).replace(/"/g, '"'); + return ` +
+
+
${i18n.t('api_keys.item_title')} #${index + 1}
+
${maskedDisplay}
+
+
+ + +
+
+ `; + }).join(''); + }, + + // 遮蔽API密钥显示 + maskApiKey(key) { + if (key === null || key === undefined) { + return ''; + } + const normalizedKey = typeof key === 'string' ? key : String(key); + if (normalizedKey.length > 8) { + return normalizedKey.substring(0, 4) + '...' + normalizedKey.substring(normalizedKey.length - 4); + } else if (normalizedKey.length > 4) { + return normalizedKey.substring(0, 2) + '...' + normalizedKey.substring(normalizedKey.length - 2); + } else if (normalizedKey.length > 2) { + return normalizedKey.substring(0, 1) + '...' + normalizedKey.substring(normalizedKey.length - 1); + } + return normalizedKey; + }, + + // HTML 转义,防止 XSS + escapeHtml(value) { + if (value === null || value === undefined) return ''; + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + }, + + // 兼容服务端返回的数组结构 + normalizeArrayResponse(data, key) { + if (Array.isArray(data)) { + return data; + } + if (data && Array.isArray(data[key])) { + return data[key]; + } + if (data && Array.isArray(data.items)) { + return data.items; + } + return []; + }, + + // 添加一行自定义请求头输入 + addHeaderField(wrapperId, header = {}) { + const wrapper = document.getElementById(wrapperId); + if (!wrapper) return; + + const row = document.createElement('div'); + row.className = 'header-input-row'; + const keyValue = typeof header.key === 'string' ? header.key : ''; + const valueValue = typeof header.value === 'string' ? header.value : ''; + row.innerHTML = ` +
+ + : + + +
+ `; + + const removeBtn = row.querySelector('.header-remove-btn'); + if (removeBtn) { + removeBtn.addEventListener('click', () => { + wrapper.removeChild(row); + if (wrapper.childElementCount === 0) { + this.addHeaderField(wrapperId); + } + }); + } + + wrapper.appendChild(row); + }, + + // 填充自定义请求头输入 + populateHeaderFields(wrapperId, headers = null) { + const wrapper = document.getElementById(wrapperId); + if (!wrapper) return; + wrapper.innerHTML = ''; + + const entries = (headers && typeof headers === 'object') + ? Object.entries(headers).filter(([key, value]) => key && value !== undefined && value !== null) + : []; + + if (!entries.length) { + this.addHeaderField(wrapperId); + return; + } + + entries.forEach(([key, value]) => this.addHeaderField(wrapperId, { key, value: String(value ?? '') })); + }, + + // 收集自定义请求头输入 + collectHeaderInputs(wrapperId) { + const wrapper = document.getElementById(wrapperId); + if (!wrapper) return null; + + const rows = Array.from(wrapper.querySelectorAll('.header-input-row')); + const headers = {}; + + rows.forEach(row => { + const keyInput = row.querySelector('.header-key-input'); + const valueInput = row.querySelector('.header-value-input'); + const key = keyInput ? keyInput.value.trim() : ''; + const value = valueInput ? valueInput.value.trim() : ''; + if (key && value) { + headers[key] = value; + } + }); + + return Object.keys(headers).length ? headers : null; + }, + + addApiKeyEntryField(wrapperId, entry = {}) { + const wrapper = document.getElementById(wrapperId); + if (!wrapper) return; + + const row = document.createElement('div'); + row.className = 'api-key-input-row'; + const keyValue = typeof entry?.['api-key'] === 'string' ? entry['api-key'] : ''; + const proxyValue = typeof entry?.['proxy-url'] === 'string' ? entry['proxy-url'] : ''; + row.innerHTML = ` +
+ + + +
+ `; + + const removeBtn = row.querySelector('.api-key-remove-btn'); + if (removeBtn) { + removeBtn.addEventListener('click', () => { + wrapper.removeChild(row); + if (wrapper.childElementCount === 0) { + this.addApiKeyEntryField(wrapperId); + } + }); + } + + wrapper.appendChild(row); + }, + + populateApiKeyEntryFields(wrapperId, entries = []) { + const wrapper = document.getElementById(wrapperId); + if (!wrapper) return; + wrapper.innerHTML = ''; + + if (!Array.isArray(entries) || entries.length === 0) { + this.addApiKeyEntryField(wrapperId); + return; + } + + entries.forEach(entry => this.addApiKeyEntryField(wrapperId, entry)); + }, + + collectApiKeyEntryInputs(wrapperId) { + const wrapper = document.getElementById(wrapperId); + if (!wrapper) return []; + + const rows = Array.from(wrapper.querySelectorAll('.api-key-input-row')); + const entries = []; + + rows.forEach(row => { + const keyInput = row.querySelector('.api-key-value-input'); + const proxyInput = row.querySelector('.api-key-proxy-input'); + const key = keyInput ? keyInput.value.trim() : ''; + const proxy = proxyInput ? proxyInput.value.trim() : ''; + if (key) { + entries.push({ 'api-key': key, 'proxy-url': proxy }); + } + }); + + return entries; + }, + + // 规范化并写入请求头 + applyHeadersToConfig(target, headers) { + if (!target) { + return; + } + if (headers && typeof headers === 'object' && Object.keys(headers).length) { + target.headers = { ...headers }; + } else { + delete target.headers; + } + }, + + // 渲染请求头徽章 + renderHeaderBadges(headers) { + if (!headers || typeof headers !== 'object') { + return ''; + } + const entries = Object.entries(headers).filter(([key, value]) => key && value !== undefined && value !== null && value !== ''); + if (!entries.length) { + return ''; + } + + const badges = entries.map(([key, value]) => ` + ${this.escapeHtml(key)}: ${this.escapeHtml(String(value))} + `).join(''); + + return ` +
+ ${i18n.t('common.custom_headers_label')}: +
+ ${badges} +
+
+ `; + }, + + // 构造Codex配置,保持未展示的字段 + buildCodexConfig(apiKey, baseUrl, proxyUrl, original = {}, headers = null) { + const result = { + ...original, + 'api-key': apiKey, + 'base-url': baseUrl || '', + 'proxy-url': proxyUrl || '' + }; + this.applyHeadersToConfig(result, headers); + return result; + } +}; + diff --git a/src/modules/auth-files.js b/src/modules/auth-files.js new file mode 100644 index 0000000..45d4001 --- /dev/null +++ b/src/modules/auth-files.js @@ -0,0 +1,967 @@ +export const authFilesModule = { + // 加载认证文件 + async loadAuthFiles(keyStats = null) { + try { + const data = await this.makeRequest('/auth-files'); + if (!keyStats) { + keyStats = await this.getKeyStats(); + } + await this.renderAuthFiles(data.files || [], keyStats); + } catch (error) { + console.error('加载认证文件失败:', error); + } + }, + + // 渲染认证文件列表 + async renderAuthFiles(files, keyStats = null) { + const container = document.getElementById('auth-files-list'); + if (!container) { + return; + } + + const allFiles = Array.isArray(files) ? files : []; + const visibleFiles = allFiles.filter(file => { + if (!file) return false; + return this.shouldDisplayDisabledGeminiCli(file) || file.disabled !== true; + }); + const stats = keyStats || await this.getKeyStats(); + + this.cachedAuthFiles = visibleFiles.map(file => ({ ...file })); + this.authFileStatsCache = stats || {}; + this.syncAuthFileControls(); + + if (this.cachedAuthFiles.length === 0) { + container.innerHTML = ` +
+ +

${i18n.t('auth_files.empty_title')}

+

${i18n.t('auth_files.empty_desc')}

+
+ `; + this.updateFilterButtons(new Set(['all'])); + this.bindAuthFileFilterEvents(); + this.applyAuthFileFilterState(false); + this.authFilesPagination.currentPage = 1; + this.authFilesPagination.totalPages = 1; + this.updatePaginationControls(0); + return; + } + + const existingTypes = new Set(['all']); + this.cachedAuthFiles.forEach(file => { + if (file.type) { + existingTypes.add(file.type); + } + }); + + this.updateFilterButtons(existingTypes); + this.bindAuthFileFilterEvents(); + this.applyAuthFileFilterState(false); + this.renderAuthFilesPage(this.authFilesPagination.currentPage); + this.bindAuthFileActionEvents(); + }, + + isRuntimeOnlyAuthFile(file) { + if (!file) return false; + const runtimeValue = file.runtime_only; + return runtimeValue === true || runtimeValue === 'true'; + }, + + shouldDisplayDisabledGeminiCli(file) { + if (!file) return false; + const provider = typeof file.provider === 'string' ? file.provider.toLowerCase() : ''; + const type = typeof file.type === 'string' ? file.type.toLowerCase() : ''; + const isGeminiCli = provider === 'gemini-cli' || type === 'gemini-cli'; + return isGeminiCli && !this.isRuntimeOnlyAuthFile(file); + }, + + resolveAuthFileStats(file, stats = {}) { + const rawFileName = typeof file?.name === 'string' ? file.name : ''; + const defaultStats = { success: 0, failure: 0 }; + if (!rawFileName) { + return defaultStats; + } + + const fromName = stats[rawFileName]; + if (fromName && (fromName.success > 0 || fromName.failure > 0)) { + return fromName; + } + + let fileStats = fromName || defaultStats; + if (fileStats.success === 0 && fileStats.failure === 0) { + const nameWithoutExt = rawFileName.replace(/\.[^/.]+$/, ""); + + if (nameWithoutExt && nameWithoutExt !== rawFileName) { + const candidateNames = new Set([nameWithoutExt]); + const normalizedName = nameWithoutExt.toLowerCase(); + const typePrefix = typeof file?.type === 'string' ? file.type.trim().toLowerCase() : ''; + const providerPrefix = typeof file?.provider === 'string' ? file.provider.trim().toLowerCase() : ''; + const prefixList = []; + + if (typePrefix) { + prefixList.push(`${typePrefix}-`); + } + if (providerPrefix && providerPrefix !== typePrefix) { + prefixList.push(`${providerPrefix}-`); + } + + prefixList.forEach(prefix => { + if (prefix && normalizedName.startsWith(prefix)) { + const trimmed = nameWithoutExt.substring(prefix.length); + if (trimmed) { + candidateNames.add(trimmed); + } + } + }); + + for (const candidate of candidateNames) { + const candidateStats = stats[candidate]; + if (candidateStats && (candidateStats.success > 0 || candidateStats.failure > 0)) { + fileStats = candidateStats; + break; + } + } + } + } + + return fileStats || defaultStats; + }, + + buildAuthFileItemHtml(file) { + const rawFileName = typeof file?.name === 'string' ? file.name : ''; + const safeFileName = this.escapeHtml(rawFileName); + const stats = this.authFileStatsCache || {}; + const fileStats = this.resolveAuthFileStats(file, stats); + const fileType = file.type || 'unknown'; + let typeDisplayKey; + switch (fileType) { + case 'qwen': + typeDisplayKey = 'auth_files.type_qwen'; + break; + case 'gemini': + typeDisplayKey = 'auth_files.type_gemini'; + break; + case 'gemini-cli': + typeDisplayKey = 'auth_files.type_gemini-cli'; + break; + case 'aistudio': + typeDisplayKey = 'auth_files.type_aistudio'; + break; + case 'claude': + typeDisplayKey = 'auth_files.type_claude'; + break; + case 'codex': + typeDisplayKey = 'auth_files.type_codex'; + break; + case 'iflow': + typeDisplayKey = 'auth_files.type_iflow'; + break; + case 'vertex': + typeDisplayKey = 'auth_files.type_vertex'; + break; + case 'empty': + typeDisplayKey = 'auth_files.type_empty'; + break; + default: + typeDisplayKey = 'auth_files.type_unknown'; + break; + } + const typeBadge = `${i18n.t(typeDisplayKey)}`; + const isRuntimeOnly = this.isRuntimeOnlyAuthFile(file); + const shouldShowMainFlag = this.shouldDisplayDisabledGeminiCli(file); + const mainFlagButton = shouldShowMainFlag ? ` + ` : ''; + const actionsHtml = isRuntimeOnly ? ` +
+ 虚拟认证文件 +
` : ` +
+ ${mainFlagButton} + + + +
`; + + return ` +
+
+
${typeBadge}${safeFileName}
+
+ ${i18n.t('auth_files.file_modified')}: ${new Date(file.modtime).toLocaleString(i18n.currentLanguage === 'zh-CN' ? 'zh-CN' : 'en-US')} + ${i18n.t('auth_files.file_size')}: ${this.formatFileSize(file.size)} +
+ +
+
+ `; + }, + + getFilteredAuthFiles(filterType = this.currentAuthFileFilter) { + const files = Array.isArray(this.cachedAuthFiles) ? this.cachedAuthFiles : []; + if (!files.length) { + return []; + } + + const typeFilter = (filterType || 'all').toLowerCase(); + const keyword = (this.authFileSearchQuery || '').trim().toLowerCase(); + + return files.filter(file => { + const name = String(file?.name || '').toLowerCase(); + const type = String(file?.type || '').toLowerCase(); + const provider = String(file?.provider || '').toLowerCase(); + + if (typeFilter !== 'all') { + if (!type) return false; + if (type !== typeFilter) { + if (!provider || provider !== typeFilter) { + return false; + } + } + } + + if (!keyword) return true; + return name.includes(keyword) || type.includes(keyword) || provider.includes(keyword); + }); + }, + + updateAuthFileSearchQuery(value = '') { + const normalized = (value || '').trim(); + if (this.authFileSearchQuery === normalized) { + return; + } + this.authFileSearchQuery = normalized; + this.authFilesPagination.currentPage = 1; + this.renderAuthFilesPage(1); + }, + + updateAuthFilesPageSize(value) { + const normalized = this.normalizeAuthFilesPageSize(value); + if (this.authFilesPagination?.pageSize === normalized) { + this.syncAuthFileControls(); + return; + } + this.authFilesPagination.pageSize = normalized; + this.authFilesPagination.currentPage = 1; + try { + localStorage.setItem(this.authFilesPageSizeKey, `${normalized}`); + } catch (error) { + console.warn('Failed to persist auth files page size:', error); + } + this.syncAuthFileControls(); + this.renderAuthFilesPage(1); + }, + + syncAuthFileControls() { + const searchInput = document.getElementById('auth-files-search-input'); + if (searchInput && searchInput.value !== this.authFileSearchQuery) { + searchInput.value = this.authFileSearchQuery; + } + + const pageSizeInput = document.getElementById('auth-files-page-size-input'); + const targetSize = this.authFilesPagination?.pageSize || 9; + if (pageSizeInput && parseInt(pageSizeInput.value, 10) !== targetSize) { + pageSizeInput.value = targetSize; + } + }, + + bindAuthFilesSearchControl() { + const searchInput = document.getElementById('auth-files-search-input'); + if (!searchInput) return; + + if (searchInput._authFileSearchListener) { + searchInput.removeEventListener('input', searchInput._authFileSearchListener); + } + + const debounced = this.debounce((value) => { + this.updateAuthFileSearchQuery(value); + }, 250); + + const listener = (event) => { + const value = event?.target?.value ?? ''; + debounced(value); + }; + + searchInput._authFileSearchListener = listener; + searchInput.addEventListener('input', listener); + }, + + bindAuthFilesPageSizeControl() { + const pageSizeInput = document.getElementById('auth-files-page-size-input'); + if (!pageSizeInput) return; + + if (pageSizeInput._authFilePageSizeListener) { + pageSizeInput.removeEventListener('change', pageSizeInput._authFilePageSizeListener); + } + + const listener = (event) => { + const value = parseInt(event?.target?.value, 10); + if (!Number.isFinite(value)) { + return; + } + this.updateAuthFilesPageSize(value); + }; + + pageSizeInput._authFilePageSizeListener = listener; + pageSizeInput.addEventListener('change', listener); + + if (pageSizeInput._authFilePageSizeBlur) { + pageSizeInput.removeEventListener('blur', pageSizeInput._authFilePageSizeBlur); + } + + const blurListener = () => { + if (!pageSizeInput.value) { + this.syncAuthFileControls(); + } + }; + + pageSizeInput._authFilePageSizeBlur = blurListener; + pageSizeInput.addEventListener('blur', blurListener); + }, + + renderAuthFilesPage(page = null) { + const container = document.getElementById('auth-files-list'); + if (!container) return; + + const pageSize = this.authFilesPagination?.pageSize || 9; + const filteredFiles = this.getFilteredAuthFiles(); + const totalItems = filteredFiles.length; + const hasCachedFiles = Array.isArray(this.cachedAuthFiles) && this.cachedAuthFiles.length > 0; + const filterApplied = (this.currentAuthFileFilter && this.currentAuthFileFilter !== 'all'); + const searchApplied = Boolean((this.authFileSearchQuery || '').trim()); + + if (totalItems === 0) { + const titleKey = hasCachedFiles && (filterApplied || searchApplied) + ? 'auth_files.search_empty_title' + : 'auth_files.empty_title'; + const descKey = hasCachedFiles && (filterApplied || searchApplied) + ? 'auth_files.search_empty_desc' + : 'auth_files.empty_desc'; + container.innerHTML = ` +
+ +

${i18n.t(titleKey)}

+

${i18n.t(descKey)}

+
+ `; + this.authFilesPagination.currentPage = 1; + this.updatePaginationControls(0); + return; + } + + const maxPages = Math.max(1, Math.ceil(totalItems / pageSize)); + let currentPage = page == null ? (this.authFilesPagination.currentPage || 1) : page; + if (currentPage > maxPages) { + currentPage = maxPages; + } + if (currentPage < 1) { + currentPage = 1; + } + this.authFilesPagination.currentPage = currentPage; + this.authFilesPagination.totalPages = maxPages; + + const startIndex = (currentPage - 1) * pageSize; + const pageFiles = filteredFiles.slice(startIndex, startIndex + pageSize); + + container.innerHTML = pageFiles.map(file => this.buildAuthFileItemHtml(file)).join(''); + this.updatePaginationControls(totalItems); + this.bindAuthFileActionEvents(); + }, + + bindAuthFilesPaginationEvents() { + const container = document.getElementById('auth-files-pagination'); + if (!container) return; + + const oldListener = container._paginationListener; + if (oldListener) { + container.removeEventListener('click', oldListener); + } + + const listener = (event) => { + const button = event.target.closest('button[data-action]'); + if (!button || !container.contains(button)) return; + event.preventDefault(); + const action = button.dataset.action; + const currentPage = this.authFilesPagination?.currentPage || 1; + if (action === 'prev') { + this.renderAuthFilesPage(currentPage - 1); + } else if (action === 'next') { + this.renderAuthFilesPage(currentPage + 1); + } + }; + + container._paginationListener = listener; + container.addEventListener('click', listener); + }, + + updatePaginationControls(totalItems = 0) { + const paginationContainer = document.getElementById('auth-files-pagination'); + const infoEl = document.getElementById('auth-files-pagination-info'); + if (!paginationContainer || !infoEl) return; + + const pageSize = this.authFilesPagination?.pageSize || 9; + const currentPage = this.authFilesPagination?.currentPage || 1; + const totalPages = Math.max(1, Math.ceil(totalItems / pageSize)); + + const start = (totalItems === 0) ? 0 : (currentPage - 1) * pageSize + 1; + const end = Math.min(totalItems, currentPage * pageSize); + + infoEl.textContent = totalItems === 0 + ? i18n.t('auth_files.pagination_empty') + : i18n.t('auth_files.pagination_info') + .replace('{start}', start) + .replace('{end}', end) + .replace('{total}', totalItems); + + const prevBtn = paginationContainer.querySelector('button[data-page=\"prev\"]'); + const nextBtn = paginationContainer.querySelector('button[data-page=\"next\"]'); + if (!prevBtn || !nextBtn) return; + prevBtn.disabled = currentPage <= 1; + nextBtn.disabled = currentPage >= totalPages; + + prevBtn.onclick = () => { + if (this.authFilesPagination.currentPage <= 1) return; + this.renderAuthFilesPage(currentPage - 1); + }; + + nextBtn.onclick = () => { + if (this.authFilesPagination.currentPage >= totalPages) return; + this.renderAuthFilesPage(currentPage + 1); + }; + }, + + updateFilterButtons(existingTypes) { + const filterContainer = document.querySelector('.auth-file-filter'); + if (!filterContainer) return; + + const existingButtons = Array.from(filterContainer.querySelectorAll('.filter-btn')); + const buttonMap = new Map(); + existingButtons.forEach(btn => { + const type = btn.dataset.type || ''; + if (type) { + buttonMap[type] = btn; + } + }); + + existingTypes.forEach(type => { + const typeKey = type || 'all'; + const normalized = typeKey.toLowerCase(); + let btn = filterContainer.querySelector(`.filter-btn[data-type=\"${normalized}\"]`); + + if (!btn) { + const labelKey = normalized === 'all' + ? 'auth_files.filter_all' + : `auth_files.filter_${normalized}`; + const label = i18n.t(labelKey); + btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'btn btn-small filter-btn'; + btn.dataset.type = normalized; + btn.setAttribute('data-i18n-text', labelKey); + btn.textContent = label; + const emptyBtn = filterContainer.querySelector('.filter-btn[data-type=\"all\"]'); + if (emptyBtn) { + filterContainer.insertBefore(btn, emptyBtn); + } else { + filterContainer.appendChild(btn); + } + } + }); + }, + + handleFilterClick(clickedBtn, options = {}) { + if (!clickedBtn) return; + const { skipRender = false } = options; + const filterBtns = document.querySelectorAll('.auth-file-filter .filter-btn'); + + filterBtns.forEach(b => b.classList.remove('active')); + clickedBtn.classList.add('active'); + + const filterType = clickedBtn.dataset.type; + this.currentAuthFileFilter = filterType || 'all'; + + if (!skipRender) { + this.authFilesPagination.currentPage = 1; + this.renderAuthFilesPage(1); + } + + this.refreshFilterButtonTexts(); + }, + + generateDynamicTypeLabel(type) { + if (!type) return ''; + const key = `auth_files.type_${type}`; + const translated = i18n.t(key); + if (translated && translated !== key) { + return translated; + } + if (type.toLowerCase() === 'iflow') return 'iFlow'; + return type.charAt(0).toUpperCase() + type.slice(1); + }, + + bindAuthFileFilterEvents() { + const filterContainer = document.querySelector('.auth-file-filter'); + if (!filterContainer) return; + + if (filterContainer._filterListener) { + filterContainer.removeEventListener('click', filterContainer._filterListener); + } + + const listener = (event) => { + const button = event.target.closest('.filter-btn'); + if (!button || !filterContainer.contains(button)) return; + event.preventDefault(); + this.handleFilterClick(button); + }; + + filterContainer._filterListener = listener; + filterContainer.addEventListener('click', listener); + + this.refreshFilterButtonTexts(); + }, + + applyAuthFileFilterState(shouldRender = false) { + const filterContainer = document.querySelector('.auth-file-filter'); + if (!filterContainer) return; + + const currentType = this.currentAuthFileFilter || 'all'; + const buttons = filterContainer.querySelectorAll('.filter-btn'); + if (buttons.length === 0) return; + + let targetButton = null; + buttons.forEach(btn => { + if (btn.dataset.type === currentType) { + targetButton = btn; + } + }); + + if (!targetButton) { + targetButton = filterContainer.querySelector('.filter-btn[data-type=\"all\"]') || buttons[0]; + if (targetButton) { + this.currentAuthFileFilter = targetButton.dataset.type || 'all'; + } + } + + if (targetButton) { + this.handleFilterClick(targetButton, { skipRender: !shouldRender }); + } + }, + + removeAuthFileElements(filenames = []) { + if (!Array.isArray(filenames) || filenames.length === 0) { + return; + } + + const removalSet = new Set(filenames); + this.cachedAuthFiles = (this.cachedAuthFiles || []).filter(file => file && !removalSet.has(file.name)); + + if (!this.cachedAuthFiles.length) { + this.authFilesPagination.currentPage = 1; + } + this.renderAuthFilesPage(this.authFilesPagination.currentPage); + }, + + refreshFilterButtonTexts() { + document.querySelectorAll('.auth-file-filter .filter-btn[data-i18n-text]').forEach(btn => { + const key = btn.getAttribute('data-i18n-text'); + if (key) { + btn.textContent = i18n.t(key); + } + }); + }, + + bindAuthFileActionEvents() { + const container = document.getElementById('auth-files-list'); + if (!container) return; + + const oldListener = container._authFileActionListener; + if (oldListener) { + container.removeEventListener('click', oldListener); + } + + const listener = (e) => { + const button = e.target.closest('button[data-action]'); + if (!button) return; + + const action = button.dataset.action; + const actionsContainer = button.closest('.item-actions'); + if (!actionsContainer) return; + + const filename = actionsContainer.dataset.filename; + if (!filename) return; + + switch (action) { + case 'showDetails': + this.showAuthFileDetails(filename); + break; + case 'download': + this.downloadAuthFile(filename); + break; + case 'delete': + this.deleteAuthFile(filename); + break; + } + }; + + container._authFileActionListener = listener; + container.addEventListener('click', listener); + }, + + formatFileSize(bytes) { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + }, + + openVertexFilePicker() { + const fileInput = document.getElementById('vertex-file-input'); + if (fileInput) { + fileInput.click(); + } + }, + + handleVertexFileSelection(event) { + const fileInput = event?.target; + const file = fileInput?.files?.[0] || null; + + if (fileInput) { + fileInput.value = ''; + } + + if (file && !file.name.toLowerCase().endsWith('.json')) { + this.showNotification(i18n.t('vertex_import.file_required'), 'error'); + this.vertexImportState.file = null; + this.updateVertexFileDisplay(); + this.updateVertexImportButtonState(); + return; + } + + this.vertexImportState.file = file; + this.updateVertexFileDisplay(file ? file.name : ''); + this.updateVertexImportButtonState(); + }, + + updateVertexFileDisplay(filename = '') { + const displayInput = document.getElementById('vertex-file-display'); + if (!displayInput) return; + displayInput.value = filename || ''; + }, + + updateVertexImportButtonState() { + const importBtn = document.getElementById('vertex-import-btn'); + if (!importBtn) return; + const disabled = !this.vertexImportState.file || this.vertexImportState.loading; + importBtn.disabled = disabled; + }, + + async importVertexCredential() { + if (!this.vertexImportState.file) { + this.showNotification(i18n.t('vertex_import.file_required'), 'error'); + return; + } + + const locationInput = document.getElementById('vertex-location'); + const location = locationInput ? locationInput.value.trim() : ''; + const formData = new FormData(); + formData.append('file', this.vertexImportState.file, this.vertexImportState.file.name); + if (location) { + formData.append('location', location); + } + + try { + this.vertexImportState.loading = true; + this.updateVertexImportButtonState(); + + const response = await fetch(`${this.apiUrl}/vertex/import`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${this.managementKey}` + }, + body: formData + }); + + if (!response.ok) { + let errorMessage = `HTTP ${response.status}`; + try { + const errorData = await response.json(); + errorMessage = errorData?.message || errorData?.error || errorMessage; + } catch (parseError) { + const text = await response.text(); + if (text) { + errorMessage = text; + } + } + throw new Error(errorMessage); + } + + const result = await response.json(); + this.vertexImportState.result = result; + this.renderVertexImportResult(result); + this.showNotification(i18n.t('vertex_import.success'), 'success'); + this.vertexImportState.file = null; + this.updateVertexFileDisplay(''); + } catch (error) { + console.error('Vertex credential import failed:', error); + this.showNotification(`${i18n.t('notification.import_failed')}: ${error.message}`, 'error'); + } finally { + this.vertexImportState.loading = false; + this.updateVertexImportButtonState(); + const fileInput = document.getElementById('vertex-file-input'); + if (fileInput) { + fileInput.value = ''; + } + } + }, + + renderVertexImportResult(result = null) { + const container = document.getElementById('vertex-import-result'); + const projectEl = document.getElementById('vertex-result-project'); + const emailEl = document.getElementById('vertex-result-email'); + const locationEl = document.getElementById('vertex-result-location'); + const fileEl = document.getElementById('vertex-result-file'); + if (!container || !projectEl || !emailEl || !locationEl || !fileEl) return; + + if (!result) { + container.style.display = 'none'; + this.vertexImportState.result = null; + return; + } + + this.vertexImportState.result = result; + projectEl.textContent = result.project || '-'; + emailEl.textContent = result.email || '-'; + locationEl.textContent = result.location || '-'; + fileEl.textContent = result.file || '-'; + container.style.display = 'block'; + }, + + showAuthFileDetails(filename, content) { + const detailsElement = document.getElementById('auth-file-details'); + if (!detailsElement) return; + + const file = (this.cachedAuthFiles || []).find(f => f && f.name === filename); + if (!file) return; + + const stats = this.resolveAuthFileStats(file, this.authFileStatsCache || {}); + const size = typeof file.size === 'number' ? this.formatFileSize(file.size) : '-'; + const provider = file.provider || '-'; + const type = file.type || '-'; + const createdAt = file.created_at ? new Date(file.created_at).toLocaleString('zh-CN') : '-'; + + const jsonContent = content || JSON.stringify(file, null, 2); + + const modalHtml = ` + + `; + + const oldModal = document.getElementById('json-modal'); + if (oldModal) { + oldModal.remove(); + } + + document.body.insertAdjacentHTML('beforeend', modalHtml); + const modal = document.getElementById('json-modal'); + + modal.addEventListener('click', (e) => { + if (e.target === modal) { + this.closeJsonModal(); + } + }); + + this.bindJsonModalEvents(modal); + }, + + bindJsonModalEvents(modal) { + modal.addEventListener('click', (e) => { + const button = e.target.closest('button[data-action]'); + if (!button) return; + + const action = button.dataset.action; + switch (action) { + case 'copy': + this.copyJsonContent(); + break; + case 'close': + this.closeJsonModal(); + break; + } + }); + }, + + closeJsonModal() { + const modal = document.getElementById('json-modal'); + if (modal) { + modal.remove(); + } + }, + + copyJsonContent() { + const jsonContent = document.querySelector('.json-content'); + if (jsonContent) { + const text = jsonContent.textContent; + navigator.clipboard.writeText(text).then(() => { + this.showNotification('内容已复制到剪贴板', 'success'); + }).catch(() => { + this.showNotification('复制失败', 'error'); + }); + } + }, + + async downloadAuthFile(filename) { + try { + const response = await fetch(`${this.apiUrl}/auth-files/download?name=${encodeURIComponent(filename)}`, { + headers: { + 'Authorization': `Bearer ${this.managementKey}` + } + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + window.URL.revokeObjectURL(url); + + this.showNotification(i18n.t('auth_files.download_success'), 'success'); + } catch (error) { + this.showNotification(`${i18n.t('notification.download_failed')}: ${error.message}`, 'error'); + } + }, + + async deleteAuthFile(filename) { + if (!confirm(`${i18n.t('auth_files.delete_confirm')} "${filename}" 吗?`)) return; + + try { + await this.makeRequest(`/auth-files?name=${encodeURIComponent(filename)}`, { method: 'DELETE' }); + this.removeAuthFileElements([filename]); + this.clearCache(); + await this.loadAuthFiles(); + this.showNotification(i18n.t('auth_files.delete_success'), 'success'); + } catch (error) { + this.showNotification(`${i18n.t('notification.delete_failed')}: ${error.message}`, 'error'); + } + }, + + async deleteAllAuthFiles() { + const filterType = (this.currentAuthFileFilter || 'all').toLowerCase(); + const isFiltered = filterType !== 'all'; + const typeLabel = this.generateDynamicTypeLabel(filterType); + const confirmMessage = isFiltered + ? i18n.t('auth_files.delete_filtered_confirm').replace('{type}', typeLabel) + : i18n.t('auth_files.delete_all_confirm'); + + if (!confirm(confirmMessage)) return; + + try { + if (!isFiltered) { + const response = await this.makeRequest('/auth-files?all=true', { method: 'DELETE' }); + const currentNames = (this.cachedAuthFiles || []).map(file => file.name).filter(Boolean); + if (currentNames.length > 0) { + this.removeAuthFileElements(currentNames); + } + this.clearCache(); + this.currentAuthFileFilter = 'all'; + await this.loadAuthFiles(); + this.showNotification(`${i18n.t('auth_files.delete_all_success')} ${response.deleted} ${i18n.t('auth_files.files_count')}`, 'success'); + return; + } + + const deletableFiles = (this.cachedAuthFiles || []).filter(file => { + if (!file || file.runtime_only) return false; + const fileType = (file.type || 'unknown').toLowerCase(); + return fileType === filterType; + }); + + if (deletableFiles.length === 0) { + this.showNotification(i18n.t('auth_files.delete_filtered_none').replace('{type}', typeLabel), 'info'); + return; + } + + let success = 0; + let failed = 0; + const deletedNames = []; + + for (const file of deletableFiles) { + try { + await this.makeRequest(`/auth-files?name=${encodeURIComponent(file.name)}`, { method: 'DELETE' }); + success++; + deletedNames.push(file.name); + } catch (error) { + console.error('删除认证文件失败:', file?.name, error); + failed++; + } + } + + if (deletedNames.length > 0) { + this.removeAuthFileElements(deletedNames); + } + + this.clearCache(); + this.currentAuthFileFilter = 'all'; + await this.loadAuthFiles(); + + if (failed === 0) { + const successMsg = i18n.t('auth_files.delete_filtered_success') + .replace('{count}', success) + .replace('{type}', typeLabel); + this.showNotification(successMsg, 'success'); + } else { + const warningMsg = i18n.t('auth_files.delete_filtered_partial') + .replace('{success}', success) + .replace('{failed}', failed) + .replace('{type}', typeLabel); + this.showNotification(warningMsg, 'warning'); + } + } catch (error) { + this.showNotification(`${i18n.t('notification.delete_failed')}: ${error.message}`, 'error'); + } + } +}; diff --git a/src/modules/config-editor.js b/src/modules/config-editor.js new file mode 100644 index 0000000..eca592f --- /dev/null +++ b/src/modules/config-editor.js @@ -0,0 +1,258 @@ +export const configEditorModule = { + 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(); + this.lastEditorConnectionState = this.isConnected; + }, + + 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 { + 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); + } 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'); + } + } + } +}; diff --git a/src/modules/language.js b/src/modules/language.js new file mode 100644 index 0000000..122c3f7 --- /dev/null +++ b/src/modules/language.js @@ -0,0 +1,26 @@ +export const languageModule = { + setupLanguageSwitcher() { + const loginToggle = document.getElementById('language-toggle'); + const mainToggle = document.getElementById('language-toggle-main'); + + if (loginToggle) { + loginToggle.addEventListener('click', () => this.toggleLanguage()); + } + if (mainToggle) { + mainToggle.addEventListener('click', () => this.toggleLanguage()); + } + }, + + toggleLanguage() { + const currentLang = i18n.currentLanguage; + const newLang = currentLang === 'zh-CN' ? 'en-US' : 'zh-CN'; + i18n.setLanguage(newLang); + + this.updateThemeButtons(); + this.updateConnectionStatus(); + + if (this.isLoggedIn && this.isConnected) { + this.loadAllData(true); + } + } +}; diff --git a/src/modules/login.js b/src/modules/login.js new file mode 100644 index 0000000..ec6c496 --- /dev/null +++ b/src/modules/login.js @@ -0,0 +1,265 @@ +export const loginModule = { + async checkLoginStatus() { + const savedBase = localStorage.getItem('apiBase'); + const savedKey = localStorage.getItem('managementKey'); + const wasLoggedIn = localStorage.getItem('isLoggedIn') === 'true'; + + if (savedBase && savedKey && wasLoggedIn) { + try { + console.log(i18n.t('auto_login.title')); + this.showAutoLoginLoading(); + await this.attemptAutoLogin(savedBase, savedKey); + return; + } catch (error) { + console.log(`${i18n.t('notification.login_failed')}: ${error.message}`); + localStorage.removeItem('isLoggedIn'); + this.hideAutoLoginLoading(); + } + } + + this.showLoginPage(); + this.loadLoginSettings(); + }, + + showAutoLoginLoading() { + document.getElementById('auto-login-loading').style.display = 'flex'; + document.getElementById('login-page').style.display = 'none'; + document.getElementById('main-page').style.display = 'none'; + }, + + hideAutoLoginLoading() { + document.getElementById('auto-login-loading').style.display = 'none'; + }, + + async attemptAutoLogin(apiBase, managementKey) { + try { + this.setApiBase(apiBase); + this.managementKey = managementKey; + + const savedProxy = localStorage.getItem('proxyUrl'); + if (savedProxy) { + // 代理设置会在后续的API请求中自动使用 + } + + await this.testConnection(); + + this.isLoggedIn = true; + this.hideAutoLoginLoading(); + this.showMainPage(); + + console.log(i18n.t('auto_login.title')); + return true; + } catch (error) { + console.error('自动登录失败:', error); + this.isLoggedIn = false; + this.isConnected = false; + throw error; + } + }, + + showLoginPage() { + document.getElementById('login-page').style.display = 'flex'; + document.getElementById('main-page').style.display = 'none'; + this.isLoggedIn = false; + this.updateLoginConnectionInfo(); + }, + + showMainPage() { + document.getElementById('login-page').style.display = 'none'; + document.getElementById('main-page').style.display = 'block'; + this.isLoggedIn = true; + this.updateConnectionInfo(); + }, + + async login(apiBase, managementKey) { + try { + this.setApiBase(apiBase); + this.managementKey = managementKey; + localStorage.setItem('managementKey', this.managementKey); + + await this.testConnection(); + + this.isLoggedIn = true; + localStorage.setItem('isLoggedIn', 'true'); + + this.showMainPage(); + return true; + } catch (error) { + console.error('登录失败:', error); + throw error; + } + }, + + logout() { + this.isLoggedIn = false; + this.isConnected = false; + this.clearCache(); + this.stopStatusUpdateTimer(); + + localStorage.removeItem('isLoggedIn'); + localStorage.removeItem('managementKey'); + + this.showLoginPage(); + }, + + async handleLogin() { + const apiBaseInput = document.getElementById('login-api-base'); + const managementKeyInput = document.getElementById('login-management-key'); + const managementKey = managementKeyInput ? managementKeyInput.value.trim() : ''; + + if (!managementKey) { + this.showLoginError(i18n.t('login.error_required')); + return; + } + + if (apiBaseInput && apiBaseInput.value.trim()) { + this.setApiBase(apiBaseInput.value.trim()); + } + + const submitBtn = document.getElementById('login-submit'); + const originalText = submitBtn ? submitBtn.innerHTML : ''; + + try { + if (submitBtn) { + submitBtn.innerHTML = `
${i18n.t('login.submitting')}`; + submitBtn.disabled = true; + } + this.hideLoginError(); + + this.managementKey = managementKey; + localStorage.setItem('managementKey', this.managementKey); + + await this.login(this.apiBase, this.managementKey); + } catch (error) { + this.showLoginError(`${i18n.t('login.error_title')}: ${error.message}`); + } finally { + if (submitBtn) { + submitBtn.innerHTML = originalText; + submitBtn.disabled = false; + } + } + }, + + toggleLoginKeyVisibility(button) { + const inputGroup = button.closest('.input-group'); + const keyInput = inputGroup.querySelector('input[type=\"password\"], input[type=\"text\"]'); + + if (keyInput.type === 'password') { + keyInput.type = 'text'; + button.innerHTML = ''; + } else { + keyInput.type = 'password'; + button.innerHTML = ''; + } + }, + + showLoginError(message) { + const errorDiv = document.getElementById('login-error'); + const errorMessage = document.getElementById('login-error-message'); + + errorMessage.textContent = message; + errorDiv.style.display = 'flex'; + }, + + hideLoginError() { + const errorDiv = document.getElementById('login-error'); + errorDiv.style.display = 'none'; + }, + + updateConnectionInfo() { + const apiUrlElement = document.getElementById('display-api-url'); + const statusElement = document.getElementById('display-connection-status'); + + if (apiUrlElement) { + apiUrlElement.textContent = this.apiBase || '-'; + } + + if (statusElement) { + let statusHtml = ''; + if (this.isConnected) { + statusHtml = ` ${i18n.t('common.connected')}`; + } else { + statusHtml = ` ${i18n.t('common.disconnected')}`; + } + statusElement.innerHTML = statusHtml; + } + }, + + loadLoginSettings() { + const savedBase = localStorage.getItem('apiBase'); + const savedKey = localStorage.getItem('managementKey'); + const loginKeyInput = document.getElementById('login-management-key'); + const apiBaseInput = document.getElementById('login-api-base'); + + if (savedBase) { + this.setApiBase(savedBase); + } else { + this.setApiBase(this.detectApiBaseFromLocation()); + } + + if (apiBaseInput) { + apiBaseInput.value = this.apiBase || ''; + } + + if (loginKeyInput && savedKey) { + loginKeyInput.value = savedKey; + } + + this.setupLoginAutoSave(); + }, + + setupLoginAutoSave() { + const loginKeyInput = document.getElementById('login-management-key'); + const apiBaseInput = document.getElementById('login-api-base'); + const resetButton = document.getElementById('login-reset-api-base'); + + const saveKey = (val) => { + if (val.trim()) { + this.managementKey = val; + localStorage.setItem('managementKey', this.managementKey); + } + }; + const saveKeyDebounced = this.debounce(saveKey, 500); + + if (loginKeyInput) { + loginKeyInput.addEventListener('change', (e) => saveKey(e.target.value)); + loginKeyInput.addEventListener('input', (e) => saveKeyDebounced(e.target.value)); + } + + if (apiBaseInput) { + const persistBase = (val) => { + const normalized = this.normalizeBase(val); + if (normalized) { + this.setApiBase(normalized); + } + }; + const persistBaseDebounced = this.debounce(persistBase, 500); + + apiBaseInput.addEventListener('change', (e) => persistBase(e.target.value)); + apiBaseInput.addEventListener('input', (e) => persistBaseDebounced(e.target.value)); + } + + if (resetButton) { + resetButton.addEventListener('click', () => { + const detected = this.detectApiBaseFromLocation(); + this.setApiBase(detected); + if (apiBaseInput) { + apiBaseInput.value = detected; + } + }); + } + + this.updateLoginConnectionInfo(); + }, + + updateLoginConnectionInfo() { + const connectionUrlElement = document.getElementById('login-connection-url'); + const customInput = document.getElementById('login-api-base'); + if (connectionUrlElement) { + connectionUrlElement.textContent = this.apiBase || '-'; + } + if (customInput && customInput !== document.activeElement) { + customInput.value = this.apiBase || ''; + } + } +}; diff --git a/src/modules/logs.js b/src/modules/logs.js new file mode 100644 index 0000000..833e914 --- /dev/null +++ b/src/modules/logs.js @@ -0,0 +1,410 @@ +export const logsModule = { + toggleLogsNavItem(show) { + const logsNavItem = document.getElementById('logs-nav-item'); + if (logsNavItem) { + logsNavItem.style.display = show ? '' : 'none'; + } + }, + + async refreshLogs(incremental = false) { + const logsContent = document.getElementById('logs-content'); + if (!logsContent) return; + + try { + if (incremental && !this.latestLogTimestamp) { + incremental = false; + } + + if (!incremental) { + logsContent.innerHTML = '
' + i18n.t('logs.loading') + '
'; + } + + let url = '/logs'; + if (incremental && this.latestLogTimestamp) { + url += `?after=${this.latestLogTimestamp}`; + } + + const response = await this.makeRequest(url, { + method: 'GET' + }); + + if (response && response.lines) { + if (response['latest-timestamp']) { + this.latestLogTimestamp = response['latest-timestamp']; + } + + if (incremental && response.lines.length > 0) { + 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, true); + } else if (!incremental) { + logsContent.innerHTML = '

' + + i18n.t('logs.empty_title') + '

' + + i18n.t('logs.empty_desc') + '

'; + this.latestLogTimestamp = null; + } + } else if (!incremental) { + logsContent.innerHTML = '

' + + i18n.t('logs.empty_title') + '

' + + i18n.t('logs.empty_desc') + '

'; + this.latestLogTimestamp = null; + } + } catch (error) { + console.error('加载日志失败:', error); + if (!incremental) { + const is404 = error.message && (error.message.includes('404') || error.message.includes('Not Found')); + + if (is404) { + logsContent.innerHTML = '

' + + i18n.t('logs.upgrade_required_title') + '

' + + i18n.t('logs.upgrade_required_desc') + '

'; + } else { + logsContent.innerHTML = '

' + + i18n.t('logs.load_error') + '

' + error.message + '

'; + } + } + } + }, + + renderLogs(lines, lineCount, scrollToBottom = true) { + const logsContent = document.getElementById('logs-content'); + if (!logsContent) return; + + if (!lines || lines.length === 0) { + this.displayedLogLines = []; + logsContent.innerHTML = '

' + + i18n.t('logs.empty_title') + '

' + + i18n.t('logs.empty_desc') + '

'; + return; + } + + const filteredLines = lines.filter(line => !line.includes('/v0/management/')); + let displayedLines = filteredLines; + if (filteredLines.length > this.maxDisplayLogLines) { + const linesToRemove = filteredLines.length - this.maxDisplayLogLines; + displayedLines = filteredLines.slice(linesToRemove); + } + + this.displayedLogLines = displayedLines.slice(); + + const displayedLineCount = this.displayedLogLines.length; + logsContent.innerHTML = ` +
+ ${displayedLineCount} ${i18n.t('logs.lines')} +
+
${this.buildLogsHtml(this.displayedLogLines)}
+ `; + + if (scrollToBottom) { + const logsTextElement = logsContent.querySelector('.logs-text'); + if (logsTextElement) { + logsTextElement.scrollTop = logsTextElement.scrollHeight; + } + } + }, + + appendLogs(newLines, totalLineCount) { + const logsContent = document.getElementById('logs-content'); + if (!logsContent) return; + + if (!newLines || newLines.length === 0) { + return; + } + + const logsTextElement = logsContent.querySelector('.logs-text'); + const logsInfoElement = logsContent.querySelector('.logs-info'); + + const filteredNewLines = newLines.filter(line => !line.includes('/v0/management/')); + if (filteredNewLines.length === 0) { + return; + } + + if (!logsTextElement) { + this.renderLogs(filteredNewLines, totalLineCount || filteredNewLines.length, true); + return; + } + + const isAtBottom = logsTextElement.scrollHeight - logsTextElement.scrollTop - logsTextElement.clientHeight < 50; + + this.displayedLogLines = this.displayedLogLines.concat(filteredNewLines); + if (this.displayedLogLines.length > this.maxDisplayLogLines) { + this.displayedLogLines = this.displayedLogLines.slice(this.displayedLogLines.length - this.maxDisplayLogLines); + } + + logsTextElement.innerHTML = this.buildLogsHtml(this.displayedLogLines); + + if (logsInfoElement) { + const displayedLines = this.displayedLogLines.length; + logsInfoElement.innerHTML = ` ${displayedLines} ${i18n.t('logs.lines')}`; + } + + if (isAtBottom) { + logsTextElement.scrollTop = logsTextElement.scrollHeight; + } + }, + + buildLogsHtml(lines) { + if (!lines || lines.length === 0) { + return ''; + } + + return lines.map(line => { + let processedLine = line.replace(/\[GIN\]\s+\d{4}\/\d{2}\/\d{2}\s+-\s+\d{2}:\d{2}:\d{2}\s+/g, ''); + const highlights = []; + + const statusInfo = this.detectHttpStatus(line); + if (statusInfo) { + const statusPattern = new RegExp(`\\b${statusInfo.code}\\b`); + const match = statusPattern.exec(processedLine); + if (match) { + highlights.push({ + start: match.index, + end: match.index + match[0].length, + className: `log-status-tag log-status-${statusInfo.bucket}`, + priority: 10 + }); + } + } + + const timestampPattern = /\d{4}[-/]\d{2}[-/]\d{2}[T]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?|\[\d{2}:\d{2}:\d{2}\]/g; + let match; + while ((match = timestampPattern.exec(processedLine)) !== null) { + highlights.push({ + start: match.index, + end: match.index + match[0].length, + className: 'log-timestamp', + priority: 5 + }); + } + + const bracketTimestampPattern = /\[\d{4}[-/]\d{2}[-/]\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?\]/g; + while ((match = bracketTimestampPattern.exec(processedLine)) !== null) { + highlights.push({ + start: match.index, + end: match.index + match[0].length, + className: 'log-timestamp', + priority: 5 + }); + } + + const levelPattern = /\[(ERROR|ERRO|ERR|FATAL|CRITICAL|CRIT|WARN|WARNING|INFO|DEBUG|TRACE|PANIC)\]/gi; + while ((match = levelPattern.exec(processedLine)) !== null) { + const level = match[1].toUpperCase(); + let className = 'log-level'; + if (['ERROR', 'ERRO', 'ERR', 'FATAL', 'CRITICAL', 'CRIT', 'PANIC'].includes(level)) { + className += ' log-level-error'; + } else if (['WARN', 'WARNING'].includes(level)) { + className += ' log-level-warn'; + } else if (level === 'INFO') { + className += ' log-level-info'; + } else if (['DEBUG', 'TRACE'].includes(level)) { + className += ' log-level-debug'; + } + highlights.push({ + start: match.index, + end: match.index + match[0].length, + className, + priority: 8 + }); + } + + const methodPattern = /\b(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS|CONNECT|TRACE)\b/g; + while ((match = methodPattern.exec(processedLine)) !== null) { + highlights.push({ + start: match.index, + end: match.index + match[0].length, + className: 'log-http-method', + priority: 6 + }); + } + + const urlPattern = /(https?:\/\/[^\s<>"']+)/g; + while ((match = urlPattern.exec(processedLine)) !== null) { + highlights.push({ + start: match.index, + end: match.index + match[0].length, + className: 'log-path', + priority: 4 + }); + } + + const ipPattern = /\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b/g; + while ((match = ipPattern.exec(processedLine)) !== null) { + highlights.push({ + start: match.index, + end: match.index + match[0].length, + className: 'log-ip', + priority: 7 + }); + } + + const successPattern = /\b(success|successful|succeeded|completed|ok|done|passed)\b/gi; + while ((match = successPattern.exec(processedLine)) !== null) { + highlights.push({ + start: match.index, + end: match.index + match[0].length, + className: 'log-keyword-success', + priority: 3 + }); + } + + const errorPattern = /\b(failed|failure|error|exception|panic|fatal|critical|aborted|denied|refused|timeout|invalid)\b/gi; + while ((match = errorPattern.exec(processedLine)) !== null) { + highlights.push({ + start: match.index, + end: match.index + match[0].length, + className: 'log-keyword-error', + priority: 3 + }); + } + + const headersPattern = /\b(x-[a-z0-9-]+|authorization|content-type|user-agent)\b/gi; + while ((match = headersPattern.exec(processedLine)) !== null) { + highlights.push({ + start: match.index, + end: match.index + match[0].length, + className: 'log-header-key', + priority: 2 + }); + } + + highlights.sort((a, b) => { + if (a.start === b.start) { + return b.priority - a.priority; + } + return a.start - b.start; + }); + + let cursor = 0; + let result = ''; + + highlights.forEach((highlight) => { + if (highlight.start < cursor) { + return; + } + + result += this.escapeHtml(processedLine.slice(cursor, highlight.start)); + result += `${this.escapeHtml(processedLine.slice(highlight.start, highlight.end))}`; + cursor = highlight.end; + }); + + result += this.escapeHtml(processedLine.slice(cursor)); + + return `${result}`; + }).join(''); + }, + + detectHttpStatus(line) { + if (!line) return null; + + const patterns = [ + /\|\s*([1-5]\d{2})\s*\|/, + /\b([1-5]\d{2})\s*-/, + /\b(?:GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS|CONNECT|TRACE)\s+\S+\s+([1-5]\d{2})\b/, + /\b(?:status|code|http)[:\s]+([1-5]\d{2})\b/i, + /\b([1-5]\d{2})\s+(?:OK|Created|Accepted|No Content|Moved|Found|Bad Request|Unauthorized|Forbidden|Not Found|Method Not Allowed|Internal Server Error|Bad Gateway|Service Unavailable|Gateway Timeout)\b/i + ]; + + for (const pattern of patterns) { + const match = line.match(pattern); + if (match) { + const code = parseInt(match[1], 10); + if (Number.isNaN(code)) { + continue; + } + + if (code >= 500) { + return { code, bucket: '5xx', match: match[1] }; + } + if (code >= 400) { + return { code, bucket: '4xx', match: match[1] }; + } + if (code >= 300) { + return { code, bucket: '3xx', match: match[1] }; + } + if (code >= 200) { + return { code, bucket: '2xx', match: match[1] }; + } + if (code >= 100) { + return { code, bucket: '1xx', match: match[1] }; + } + } + } + + return null; + }, + + async downloadLogs() { + try { + const response = await this.makeRequest('/logs', { + method: 'GET' + }); + + if (response && response.lines && response.lines.length > 0) { + const logsText = response.lines.join('\n'); + const blob = new Blob([logsText], { type: 'text/plain' }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `cli-proxy-api-logs-${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.log`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.URL.revokeObjectURL(url); + this.showNotification(i18n.t('logs.download_success'), 'success'); + } else { + this.showNotification(i18n.t('logs.empty_title'), 'info'); + } + } catch (error) { + console.error('下载日志失败:', error); + this.showNotification(`${i18n.t('notification.download_failed')}: ${error.message}`, 'error'); + } + }, + + async clearLogs() { + if (!confirm(i18n.t('logs.clear_confirm'))) { + return; + } + + try { + const response = await this.makeRequest('/logs', { + method: 'DELETE' + }); + + if (response && response.status === 'ok') { + const removedCount = response.removed || 0; + const message = `${i18n.t('logs.clear_success')} (${i18n.t('logs.removed')}: ${removedCount} ${i18n.t('logs.lines')})`; + this.showNotification(message, 'success'); + } else { + this.showNotification(i18n.t('logs.clear_success'), 'success'); + } + + this.latestLogTimestamp = null; + await this.refreshLogs(false); + } catch (error) { + console.error('清空日志失败:', error); + this.showNotification(`${i18n.t('notification.delete_failed')}: ${error.message}`, 'error'); + } + }, + + toggleLogsAutoRefresh(enabled) { + if (enabled) { + if (this.logsRefreshTimer) { + clearInterval(this.logsRefreshTimer); + } + this.logsRefreshTimer = setInterval(() => { + const logsSection = document.getElementById('logs'); + if (logsSection && logsSection.classList.contains('active')) { + this.refreshLogs(true); + } + }, 5000); + this.showNotification(i18n.t('logs.auto_refresh_enabled'), 'success'); + } else { + if (this.logsRefreshTimer) { + clearInterval(this.logsRefreshTimer); + this.logsRefreshTimer = null; + } + this.showNotification(i18n.t('logs.auto_refresh_disabled'), 'info'); + } + } +}; diff --git a/src/modules/navigation.js b/src/modules/navigation.js new file mode 100644 index 0000000..acd29cd --- /dev/null +++ b/src/modules/navigation.js @@ -0,0 +1,102 @@ +export const navigationModule = { + setupNavigation() { + const navItems = document.querySelectorAll('.nav-item'); + navItems.forEach(item => { + item.addEventListener('click', (e) => { + e.preventDefault(); + + navItems.forEach(nav => nav.classList.remove('active')); + document.querySelectorAll('.content-section').forEach(section => section.classList.remove('active')); + + item.classList.add('active'); + const sectionId = item.getAttribute('data-section'); + const section = document.getElementById(sectionId); + if (section) { + section.classList.add('active'); + } + + if (sectionId === 'logs') { + this.refreshLogs(false); + } else if (sectionId === 'config-management') { + this.loadConfigFileEditor(); + this.refreshConfigEditor(); + } + }); + }); + }, + + toggleMobileSidebar() { + const sidebar = document.getElementById('sidebar'); + const overlay = document.getElementById('sidebar-overlay'); + const layout = document.getElementById('layout-container'); + const mainWrapper = document.getElementById('main-wrapper'); + + if (sidebar && overlay) { + const isOpen = sidebar.classList.toggle('mobile-open'); + overlay.classList.toggle('active'); + if (layout) { + layout.classList.toggle('sidebar-open', isOpen); + } + if (mainWrapper) { + mainWrapper.classList.toggle('sidebar-open', isOpen); + } + } + }, + + closeMobileSidebar() { + const sidebar = document.getElementById('sidebar'); + const overlay = document.getElementById('sidebar-overlay'); + const layout = document.getElementById('layout-container'); + const mainWrapper = document.getElementById('main-wrapper'); + + if (sidebar && overlay) { + sidebar.classList.remove('mobile-open'); + overlay.classList.remove('active'); + if (layout) { + layout.classList.remove('sidebar-open'); + } + if (mainWrapper) { + mainWrapper.classList.remove('sidebar-open'); + } + } + }, + + toggleSidebar() { + const sidebar = document.getElementById('sidebar'); + const layout = document.getElementById('layout-container'); + + if (sidebar && layout) { + const isCollapsed = sidebar.classList.toggle('collapsed'); + layout.classList.toggle('sidebar-collapsed', isCollapsed); + + localStorage.setItem('sidebarCollapsed', isCollapsed ? 'true' : 'false'); + + const toggleBtn = document.getElementById('sidebar-toggle-btn-desktop'); + if (toggleBtn) { + toggleBtn.setAttribute('data-i18n-title', isCollapsed ? 'sidebar.toggle_expand' : 'sidebar.toggle_collapse'); + toggleBtn.title = i18n.t(isCollapsed ? 'sidebar.toggle_expand' : 'sidebar.toggle_collapse'); + } + } + }, + + restoreSidebarState() { + if (window.innerWidth > 1024) { + const savedState = localStorage.getItem('sidebarCollapsed'); + if (savedState === 'true') { + const sidebar = document.getElementById('sidebar'); + const layout = document.getElementById('layout-container'); + + if (sidebar && layout) { + sidebar.classList.add('collapsed'); + layout.classList.add('sidebar-collapsed'); + + const toggleBtn = document.getElementById('sidebar-toggle-btn-desktop'); + if (toggleBtn) { + toggleBtn.setAttribute('data-i18n-title', 'sidebar.toggle_expand'); + toggleBtn.title = i18n.t('sidebar.toggle_expand'); + } + } + } + } + } +}; diff --git a/src/modules/oauth.js b/src/modules/oauth.js new file mode 100644 index 0000000..c29af47 --- /dev/null +++ b/src/modules/oauth.js @@ -0,0 +1,738 @@ +export const oauthModule = { + // ===== Codex OAuth 相关方法 ===== + + // 开始 Codex OAuth 流程 + async startCodexOAuth() { + try { + const response = await this.makeRequest('/codex-auth-url?is_webui=1'); + const authUrl = response.url; + const state = this.extractStateFromUrl(authUrl); + + // 显示授权链接 + const urlInput = document.getElementById('codex-oauth-url'); + const content = document.getElementById('codex-oauth-content'); + const status = document.getElementById('codex-oauth-status'); + + if (urlInput) { + urlInput.value = authUrl; + } + if (content) { + content.style.display = 'block'; + } + if (status) { + status.textContent = i18n.t('auth_login.codex_oauth_status_waiting'); + status.style.color = 'var(--warning-text)'; + } + + // 开始轮询认证状态 + this.startCodexOAuthPolling(state); + + } catch (error) { + this.showNotification(`${i18n.t('auth_login.codex_oauth_start_error')} ${error.message}`, 'error'); + } + }, + + // 从 URL 中提取 state 参数 + extractStateFromUrl(url) { + try { + const urlObj = new URL(url); + return urlObj.searchParams.get('state'); + } catch (error) { + console.error('Failed to extract state from URL:', error); + return null; + } + }, + + // 打开 Codex 授权链接 + openCodexLink() { + const urlInput = document.getElementById('codex-oauth-url'); + if (urlInput && urlInput.value) { + window.open(urlInput.value, '_blank'); + } + }, + + // 复制 Codex 授权链接 + async copyCodexLink() { + const urlInput = document.getElementById('codex-oauth-url'); + if (urlInput && urlInput.value) { + try { + await navigator.clipboard.writeText(urlInput.value); + this.showNotification(i18n.t('notification.link_copied'), 'success'); + } catch (error) { + // 降级方案:使用传统的复制方法 + urlInput.select(); + document.execCommand('copy'); + this.showNotification(i18n.t('notification.link_copied'), 'success'); + } + } + }, + + // 开始轮询 OAuth 状态 + startCodexOAuthPolling(state) { + if (!state) { + this.showNotification(i18n.t('auth_login.missing_state'), 'error'); + return; + } + + const pollInterval = setInterval(async () => { + try { + const response = await this.makeRequest(`/get-auth-status?state=${encodeURIComponent(state)}`); + const status = response.status; + const statusElement = document.getElementById('codex-oauth-status'); + + if (status === 'ok') { + // 认证成功 + clearInterval(pollInterval); + // 隐藏授权链接相关内容,恢复到初始状态 + this.resetCodexOAuthUI(); + // 显示成功通知 + this.showNotification(i18n.t('auth_login.codex_oauth_status_success'), 'success'); + // 刷新认证文件列表 + this.loadAuthFiles(); + } else if (status === 'error') { + // 认证失败 + clearInterval(pollInterval); + const errorMessage = response.error || 'Unknown error'; + // 显示错误信息 + if (statusElement) { + statusElement.textContent = `${i18n.t('auth_login.codex_oauth_status_error')} ${errorMessage}`; + statusElement.style.color = 'var(--error-text)'; + } + this.showNotification(`${i18n.t('auth_login.codex_oauth_status_error')} ${errorMessage}`, 'error'); + // 3秒后重置UI,让用户能够重新开始 + setTimeout(() => { + this.resetCodexOAuthUI(); + }, 3000); + } else if (status === 'wait') { + // 继续等待 + if (statusElement) { + statusElement.textContent = i18n.t('auth_login.codex_oauth_status_waiting'); + statusElement.style.color = 'var(--warning-text)'; + } + } + } catch (error) { + clearInterval(pollInterval); + const statusElement = document.getElementById('codex-oauth-status'); + if (statusElement) { + statusElement.textContent = `${i18n.t('auth_login.codex_oauth_polling_error')} ${error.message}`; + statusElement.style.color = 'var(--error-text)'; + } + this.showNotification(`${i18n.t('auth_login.codex_oauth_polling_error')} ${error.message}`, 'error'); + // 3秒后重置UI,让用户能够重新开始 + setTimeout(() => { + this.resetCodexOAuthUI(); + }, 3000); + } + }, 2000); // 每2秒轮询一次 + + // 设置超时,5分钟后停止轮询 + setTimeout(() => { + clearInterval(pollInterval); + }, 5 * 60 * 1000); + }, + + // 重置 Codex OAuth UI 到初始状态 + resetCodexOAuthUI() { + const urlInput = document.getElementById('codex-oauth-url'); + const content = document.getElementById('codex-oauth-content'); + const status = document.getElementById('codex-oauth-status'); + + // 清空并隐藏授权链接输入框 + if (urlInput) { + urlInput.value = ''; + } + + // 隐藏整个授权链接内容区域 + if (content) { + content.style.display = 'none'; + } + + // 清空状态显示 + if (status) { + status.textContent = ''; + status.style.color = ''; + status.className = ''; + } + }, + + // ===== Anthropic OAuth 相关方法 ===== + + // 开始 Anthropic OAuth 流程 + async startAnthropicOAuth() { + try { + const response = await this.makeRequest('/anthropic-auth-url?is_webui=1'); + const authUrl = response.url; + const state = this.extractStateFromUrl(authUrl); + + // 显示授权链接 + const urlInput = document.getElementById('anthropic-oauth-url'); + const content = document.getElementById('anthropic-oauth-content'); + const status = document.getElementById('anthropic-oauth-status'); + + if (urlInput) { + urlInput.value = authUrl; + } + if (content) { + content.style.display = 'block'; + } + if (status) { + status.textContent = i18n.t('auth_login.anthropic_oauth_status_waiting'); + status.style.color = 'var(--warning-text)'; + } + + // 开始轮询认证状态 + this.startAnthropicOAuthPolling(state); + + } catch (error) { + this.showNotification(`${i18n.t('auth_login.anthropic_oauth_start_error')} ${error.message}`, 'error'); + } + }, + + // 打开 Anthropic 授权链接 + openAnthropicLink() { + const urlInput = document.getElementById('anthropic-oauth-url'); + if (urlInput && urlInput.value) { + window.open(urlInput.value, '_blank'); + } + }, + + // 复制 Anthropic 授权链接 + async copyAnthropicLink() { + const urlInput = document.getElementById('anthropic-oauth-url'); + if (urlInput && urlInput.value) { + try { + await navigator.clipboard.writeText(urlInput.value); + this.showNotification(i18n.t('notification.link_copied'), 'success'); + } catch (error) { + // 降级方案:使用传统的复制方法 + urlInput.select(); + document.execCommand('copy'); + this.showNotification(i18n.t('notification.link_copied'), 'success'); + } + } + }, + + // 开始轮询 Anthropic OAuth 状态 + startAnthropicOAuthPolling(state) { + if (!state) { + this.showNotification(i18n.t('auth_login.missing_state'), 'error'); + return; + } + + const pollInterval = setInterval(async () => { + try { + const response = await this.makeRequest(`/get-auth-status?state=${encodeURIComponent(state)}`); + const status = response.status; + const statusElement = document.getElementById('anthropic-oauth-status'); + + if (status === 'ok') { + // 认证成功 + clearInterval(pollInterval); + // 隐藏授权链接相关内容,恢复到初始状态 + this.resetAnthropicOAuthUI(); + // 显示成功通知 + this.showNotification(i18n.t('auth_login.anthropic_oauth_status_success'), 'success'); + // 刷新认证文件列表 + this.loadAuthFiles(); + } else if (status === 'error') { + // 认证失败 + clearInterval(pollInterval); + const errorMessage = response.error || 'Unknown error'; + // 显示错误信息 + if (statusElement) { + statusElement.textContent = `${i18n.t('auth_login.anthropic_oauth_status_error')} ${errorMessage}`; + statusElement.style.color = 'var(--error-text)'; + } + this.showNotification(`${i18n.t('auth_login.anthropic_oauth_status_error')} ${errorMessage}`, 'error'); + // 3秒后重置UI,让用户能够重新开始 + setTimeout(() => { + this.resetAnthropicOAuthUI(); + }, 3000); + } else if (status === 'wait') { + // 继续等待 + if (statusElement) { + statusElement.textContent = i18n.t('auth_login.anthropic_oauth_status_waiting'); + statusElement.style.color = 'var(--warning-text)'; + } + } + } catch (error) { + clearInterval(pollInterval); + const statusElement = document.getElementById('anthropic-oauth-status'); + if (statusElement) { + statusElement.textContent = `${i18n.t('auth_login.anthropic_oauth_polling_error')} ${error.message}`; + statusElement.style.color = 'var(--error-text)'; + } + this.showNotification(`${i18n.t('auth_login.anthropic_oauth_polling_error')} ${error.message}`, 'error'); + // 3秒后重置UI,让用户能够重新开始 + setTimeout(() => { + this.resetAnthropicOAuthUI(); + }, 3000); + } + }, 2000); // 每2秒轮询一次 + + // 设置超时,5分钟后停止轮询 + setTimeout(() => { + clearInterval(pollInterval); + }, 5 * 60 * 1000); + }, + + // 重置 Anthropic OAuth UI 到初始状态 + resetAnthropicOAuthUI() { + const urlInput = document.getElementById('anthropic-oauth-url'); + const content = document.getElementById('anthropic-oauth-content'); + const status = document.getElementById('anthropic-oauth-status'); + + // 清空并隐藏授权链接输入框 + if (urlInput) { + urlInput.value = ''; + } + + // 隐藏整个授权链接内容区域 + if (content) { + content.style.display = 'none'; + } + + // 清空状态显示 + if (status) { + status.textContent = ''; + status.style.color = ''; + status.className = ''; + } + }, + + // ===== Gemini CLI OAuth 相关方法 ===== + + // 开始 Gemini CLI OAuth 流程 + async startGeminiCliOAuth() { + try { + const response = await this.makeRequest('/gemini-cli-auth-url?is_webui=1'); + const authUrl = response.url; + const state = this.extractStateFromUrl(authUrl); + + // 显示授权链接 + const urlInput = document.getElementById('gemini-cli-oauth-url'); + const content = document.getElementById('gemini-cli-oauth-content'); + const status = document.getElementById('gemini-cli-oauth-status'); + + if (urlInput) { + urlInput.value = authUrl; + } + if (content) { + content.style.display = 'block'; + } + if (status) { + status.textContent = i18n.t('auth_login.gemini_cli_oauth_status_waiting'); + status.style.color = 'var(--warning-text)'; + } + + // 开始轮询认证状态 + this.startGeminiCliOAuthPolling(state); + + } catch (error) { + this.showNotification(`${i18n.t('auth_login.gemini_cli_oauth_start_error')} ${error.message}`, 'error'); + } + }, + + // 打开 Gemini CLI 授权链接 + openGeminiCliLink() { + const urlInput = document.getElementById('gemini-cli-oauth-url'); + if (urlInput && urlInput.value) { + window.open(urlInput.value, '_blank'); + } + }, + + // 复制 Gemini CLI 授权链接 + async copyGeminiCliLink() { + const urlInput = document.getElementById('gemini-cli-oauth-url'); + if (urlInput && urlInput.value) { + try { + await navigator.clipboard.writeText(urlInput.value); + this.showNotification(i18n.t('notification.link_copied'), 'success'); + } catch (error) { + // 降级方案:使用传统的复制方法 + urlInput.select(); + document.execCommand('copy'); + this.showNotification(i18n.t('notification.link_copied'), 'success'); + } + } + }, + + // 开始轮询 Gemini CLI OAuth 状态 + startGeminiCliOAuthPolling(state) { + if (!state) { + this.showNotification(i18n.t('auth_login.missing_state'), 'error'); + return; + } + + const pollInterval = setInterval(async () => { + try { + const response = await this.makeRequest(`/get-auth-status?state=${encodeURIComponent(state)}`); + const status = response.status; + const statusElement = document.getElementById('gemini-cli-oauth-status'); + + if (status === 'ok') { + // 认证成功 + clearInterval(pollInterval); + // 隐藏授权链接相关内容,恢复到初始状态 + this.resetGeminiCliOAuthUI(); + // 显示成功通知 + this.showNotification(i18n.t('auth_login.gemini_cli_oauth_status_success'), 'success'); + // 刷新认证文件列表 + this.loadAuthFiles(); + } else if (status === 'error') { + // 认证失败 + clearInterval(pollInterval); + const errorMessage = response.error || 'Unknown error'; + // 显示错误信息 + if (statusElement) { + statusElement.textContent = `${i18n.t('auth_login.gemini_cli_oauth_status_error')} ${errorMessage}`; + statusElement.style.color = 'var(--error-text)'; + } + this.showNotification(`${i18n.t('auth_login.gemini_cli_oauth_status_error')} ${errorMessage}`, 'error'); + // 3秒后重置UI,让用户能够重新开始 + setTimeout(() => { + this.resetGeminiCliOAuthUI(); + }, 3000); + } else if (status === 'wait') { + // 继续等待 + if (statusElement) { + statusElement.textContent = i18n.t('auth_login.gemini_cli_oauth_status_waiting'); + statusElement.style.color = 'var(--warning-text)'; + } + } + } catch (error) { + clearInterval(pollInterval); + const statusElement = document.getElementById('gemini-cli-oauth-status'); + if (statusElement) { + statusElement.textContent = `${i18n.t('auth_login.gemini_cli_oauth_polling_error')} ${error.message}`; + statusElement.style.color = 'var(--error-text)'; + } + this.showNotification(`${i18n.t('auth_login.gemini_cli_oauth_polling_error')} ${error.message}`, 'error'); + // 3秒后重置UI,让用户能够重新开始 + setTimeout(() => { + this.resetGeminiCliOAuthUI(); + }, 3000); + } + }, 2000); // 每2秒轮询一次 + + // 设置超时,5分钟后停止轮询 + setTimeout(() => { + clearInterval(pollInterval); + }, 5 * 60 * 1000); + }, + + // 重置 Gemini CLI OAuth UI 到初始状态 + resetGeminiCliOAuthUI() { + const urlInput = document.getElementById('gemini-cli-oauth-url'); + const content = document.getElementById('gemini-cli-oauth-content'); + const status = document.getElementById('gemini-cli-oauth-status'); + + // 清空并隐藏授权链接输入框 + if (urlInput) { + urlInput.value = ''; + } + + // 隐藏整个授权链接内容区域 + if (content) { + content.style.display = 'none'; + } + + // 清空状态显示 + if (status) { + status.textContent = ''; + status.style.color = ''; + status.className = ''; + } + }, + + // ===== Qwen OAuth 相关方法 ===== + + // 开始 Qwen OAuth 流程 + async startQwenOAuth() { + try { + const response = await this.makeRequest('/qwen-auth-url?is_webui=1'); + const authUrl = response.url; + const state = this.extractStateFromUrl(authUrl); + + // 显示授权链接 + const urlInput = document.getElementById('qwen-oauth-url'); + const content = document.getElementById('qwen-oauth-content'); + const status = document.getElementById('qwen-oauth-status'); + + if (urlInput) { + urlInput.value = authUrl; + } + if (content) { + content.style.display = 'block'; + } + if (status) { + status.textContent = i18n.t('auth_login.qwen_oauth_status_waiting'); + status.style.color = 'var(--warning-text)'; + } + + // 开始轮询认证状态 + this.startQwenOAuthPolling(state); + + } catch (error) { + this.showNotification(`${i18n.t('auth_login.qwen_oauth_start_error')} ${error.message}`, 'error'); + } + }, + + // 打开 Qwen 授权链接 + openQwenLink() { + const urlInput = document.getElementById('qwen-oauth-url'); + if (urlInput && urlInput.value) { + window.open(urlInput.value, '_blank'); + } + }, + + // 复制 Qwen 授权链接 + async copyQwenLink() { + const urlInput = document.getElementById('qwen-oauth-url'); + if (urlInput && urlInput.value) { + try { + await navigator.clipboard.writeText(urlInput.value); + this.showNotification(i18n.t('notification.link_copied'), 'success'); + } catch (error) { + // 降级方案:使用传统的复制方法 + urlInput.select(); + document.execCommand('copy'); + this.showNotification(i18n.t('notification.link_copied'), 'success'); + } + } + }, + + // 开始轮询 Qwen OAuth 状态 + startQwenOAuthPolling(state) { + if (!state) { + this.showNotification(i18n.t('auth_login.missing_state'), 'error'); + return; + } + + const pollInterval = setInterval(async () => { + try { + const response = await this.makeRequest(`/get-auth-status?state=${encodeURIComponent(state)}`); + const status = response.status; + const statusElement = document.getElementById('qwen-oauth-status'); + + if (status === 'ok') { + // 认证成功 + clearInterval(pollInterval); + // 隐藏授权链接相关内容,恢复到初始状态 + this.resetQwenOAuthUI(); + // 显示成功通知 + this.showNotification(i18n.t('auth_login.qwen_oauth_status_success'), 'success'); + // 刷新认证文件列表 + this.loadAuthFiles(); + } else if (status === 'error') { + // 认证失败 + clearInterval(pollInterval); + const errorMessage = response.error || 'Unknown error'; + // 显示错误信息 + if (statusElement) { + statusElement.textContent = `${i18n.t('auth_login.qwen_oauth_status_error')} ${errorMessage}`; + statusElement.style.color = 'var(--error-text)'; + } + this.showNotification(`${i18n.t('auth_login.qwen_oauth_status_error')} ${errorMessage}`, 'error'); + // 3秒后重置UI,让用户能够重新开始 + setTimeout(() => { + this.resetQwenOAuthUI(); + }, 3000); + } else if (status === 'wait') { + // 继续等待 + if (statusElement) { + statusElement.textContent = i18n.t('auth_login.qwen_oauth_status_waiting'); + statusElement.style.color = 'var(--warning-text)'; + } + } + } catch (error) { + clearInterval(pollInterval); + const statusElement = document.getElementById('qwen-oauth-status'); + if (statusElement) { + statusElement.textContent = `${i18n.t('auth_login.qwen_oauth_polling_error')} ${error.message}`; + statusElement.style.color = 'var(--error-text)'; + } + this.showNotification(`${i18n.t('auth_login.qwen_oauth_polling_error')} ${error.message}`, 'error'); + // 3秒后重置UI,让用户能够重新开始 + setTimeout(() => { + this.resetQwenOAuthUI(); + }, 3000); + } + }, 2000); // 每2秒轮询一次 + + // 设置超时,5分钟后停止轮询 + setTimeout(() => { + clearInterval(pollInterval); + }, 5 * 60 * 1000); + }, + + // 重置 Qwen OAuth UI 到初始状态 + resetQwenOAuthUI() { + const urlInput = document.getElementById('qwen-oauth-url'); + const content = document.getElementById('qwen-oauth-content'); + const status = document.getElementById('qwen-oauth-status'); + + // 清空并隐藏授权链接输入框 + if (urlInput) { + urlInput.value = ''; + } + + // 隐藏整个授权链接内容区域 + if (content) { + content.style.display = 'none'; + } + + // 清空状态显示 + if (status) { + status.textContent = ''; + status.style.color = ''; + status.className = ''; + } + }, + + // ===== iFlow OAuth 相关方法 ===== + + // 开始 iFlow OAuth 流程 + async startIflowOAuth() { + try { + const response = await this.makeRequest('/iflow-auth-url?is_webui=1'); + const authUrl = response.url; + const state = this.extractStateFromUrl(authUrl); + + // 显示授权链接 + const urlInput = document.getElementById('iflow-oauth-url'); + const content = document.getElementById('iflow-oauth-content'); + const status = document.getElementById('iflow-oauth-status'); + + if (urlInput) { + urlInput.value = authUrl; + } + if (content) { + content.style.display = 'block'; + } + if (status) { + status.textContent = i18n.t('auth_login.iflow_oauth_status_waiting'); + status.style.color = 'var(--warning-text)'; + } + + // 开始轮询认证状态 + this.startIflowOAuthPolling(state); + + } catch (error) { + this.showNotification(`${i18n.t('auth_login.iflow_oauth_start_error')} ${error.message}`, 'error'); + } + }, + + // 打开 iFlow 授权链接 + openIflowLink() { + const urlInput = document.getElementById('iflow-oauth-url'); + if (urlInput && urlInput.value) { + window.open(urlInput.value, '_blank'); + } + }, + + // 复制 iFlow 授权链接 + async copyIflowLink() { + const urlInput = document.getElementById('iflow-oauth-url'); + if (urlInput && urlInput.value) { + try { + await navigator.clipboard.writeText(urlInput.value); + this.showNotification(i18n.t('notification.link_copied'), 'success'); + } catch (error) { + // 降级方案:使用传统的复制方法 + urlInput.select(); + document.execCommand('copy'); + this.showNotification(i18n.t('notification.link_copied'), 'success'); + } + } + }, + + // 开始轮询 iFlow OAuth 状态 + startIflowOAuthPolling(state) { + if (!state) { + this.showNotification(i18n.t('auth_login.missing_state'), 'error'); + return; + } + + const pollInterval = setInterval(async () => { + try { + const response = await this.makeRequest(`/get-auth-status?state=${encodeURIComponent(state)}`); + const status = response.status; + const statusElement = document.getElementById('iflow-oauth-status'); + + if (status === 'ok') { + // 认证成功 + clearInterval(pollInterval); + // 隐藏授权链接相关内容,恢复到初始状态 + this.resetIflowOAuthUI(); + // 显示成功通知 + this.showNotification(i18n.t('auth_login.iflow_oauth_status_success'), 'success'); + // 刷新认证文件列表 + this.loadAuthFiles(); + } else if (status === 'error') { + // 认证失败 + clearInterval(pollInterval); + const errorMessage = response.error || 'Unknown error'; + // 显示错误信息 + if (statusElement) { + statusElement.textContent = `${i18n.t('auth_login.iflow_oauth_status_error')} ${errorMessage}`; + statusElement.style.color = 'var(--error-text)'; + } + this.showNotification(`${i18n.t('auth_login.iflow_oauth_status_error')} ${errorMessage}`, 'error'); + // 3秒后重置UI,让用户能够重新开始 + setTimeout(() => { + this.resetIflowOAuthUI(); + }, 3000); + } else if (status === 'wait') { + // 继续等待 + if (statusElement) { + statusElement.textContent = i18n.t('auth_login.iflow_oauth_status_waiting'); + statusElement.style.color = 'var(--warning-text)'; + } + } + } catch (error) { + clearInterval(pollInterval); + const statusElement = document.getElementById('iflow-oauth-status'); + if (statusElement) { + statusElement.textContent = `${i18n.t('auth_login.iflow_oauth_polling_error')} ${error.message}`; + statusElement.style.color = 'var(--error-text)'; + } + this.showNotification(`${i18n.t('auth_login.iflow_oauth_polling_error')} ${error.message}`, 'error'); + // 3秒后重置UI,让用户能够重新开始 + setTimeout(() => { + this.resetIflowOAuthUI(); + }, 3000); + } + }, 2000); // 每2秒轮询一次 + + // 设置超时,5分钟后停止轮询 + setTimeout(() => { + clearInterval(pollInterval); + }, 5 * 60 * 1000); + }, + + // 重置 iFlow OAuth UI 到初始状态 + resetIflowOAuthUI() { + const urlInput = document.getElementById('iflow-oauth-url'); + const content = document.getElementById('iflow-oauth-content'); + const status = document.getElementById('iflow-oauth-status'); + + // 清空并隐藏授权链接输入框 + if (urlInput) { + urlInput.value = ''; + } + + // 隐藏整个授权链接内容区域 + if (content) { + content.style.display = 'none'; + } + + // 清空状态显示 + if (status) { + status.textContent = ''; + status.style.color = ''; + status.className = ''; + } + } +}; + diff --git a/src/modules/settings.js b/src/modules/settings.js new file mode 100644 index 0000000..48bf65b --- /dev/null +++ b/src/modules/settings.js @@ -0,0 +1,271 @@ +// 设置与开关相关方法模块 +// 注意:这些函数依赖于在 CLIProxyManager 实例上提供的 makeRequest/clearCache/showNotification 等基础能力 + +export async function updateDebug(enabled) { + try { + await this.makeRequest('/debug', { + method: 'PUT', + body: JSON.stringify({ value: enabled }) + }); + this.clearCache(); // 清除缓存 + this.showNotification(i18n.t('notification.debug_updated'), 'success'); + } catch (error) { + this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error'); + // 恢复原状态 + document.getElementById('debug-toggle').checked = !enabled; + } +} + +export async function updateProxyUrl() { + const proxyUrl = document.getElementById('proxy-url').value.trim(); + + try { + await this.makeRequest('/proxy-url', { + method: 'PUT', + body: JSON.stringify({ value: proxyUrl }) + }); + this.clearCache(); // 清除缓存 + this.showNotification(i18n.t('notification.proxy_updated'), 'success'); + } catch (error) { + this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error'); + } +} + +export async function clearProxyUrl() { + try { + await this.makeRequest('/proxy-url', { method: 'DELETE' }); + document.getElementById('proxy-url').value = ''; + this.clearCache(); // 清除缓存 + this.showNotification(i18n.t('notification.proxy_cleared'), 'success'); + } catch (error) { + this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error'); + } +} + +export async function updateRequestRetry() { + const retryCount = parseInt(document.getElementById('request-retry').value); + + try { + await this.makeRequest('/request-retry', { + method: 'PUT', + body: JSON.stringify({ value: retryCount }) + }); + this.clearCache(); // 清除缓存 + this.showNotification(i18n.t('notification.retry_updated'), 'success'); + } catch (error) { + this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error'); + } +} + +export async function loadDebugSettings() { + try { + const config = await this.getConfig(); + if (config.debug !== undefined) { + document.getElementById('debug-toggle').checked = config.debug; + } + } catch (error) { + console.error('加载调试设置失败:', error); + } +} + +export async function loadProxySettings() { + try { + const config = await this.getConfig(); + if (config['proxy-url'] !== undefined) { + document.getElementById('proxy-url').value = config['proxy-url'] || ''; + } + } catch (error) { + console.error('加载代理设置失败:', error); + } +} + +export async function loadRetrySettings() { + try { + const config = await this.getConfig(); + if (config['request-retry'] !== undefined) { + document.getElementById('request-retry').value = config['request-retry']; + } + } catch (error) { + console.error('加载重试设置失败:', error); + } +} + +export async function loadQuotaSettings() { + try { + const config = await this.getConfig(); + if (config['quota-exceeded']) { + if (config['quota-exceeded']['switch-project'] !== undefined) { + document.getElementById('switch-project-toggle').checked = config['quota-exceeded']['switch-project']; + } + if (config['quota-exceeded']['switch-preview-model'] !== undefined) { + document.getElementById('switch-preview-model-toggle').checked = config['quota-exceeded']['switch-preview-model']; + } + } + } catch (error) { + console.error('加载配额设置失败:', error); + } +} + +export async function loadUsageStatisticsSettings() { + try { + const config = await this.getConfig(); + if (config['usage-statistics-enabled'] !== undefined) { + const usageToggle = document.getElementById('usage-statistics-enabled-toggle'); + if (usageToggle) { + usageToggle.checked = config['usage-statistics-enabled']; + } + } + } catch (error) { + console.error('加载使用统计设置失败:', error); + } +} + +export async function loadRequestLogSetting() { + try { + const config = await this.getConfig(); + if (config['request-log'] !== undefined) { + const requestLogToggle = document.getElementById('request-log-toggle'); + if (requestLogToggle) { + requestLogToggle.checked = config['request-log']; + } + } + } catch (error) { + console.error('加载请求日志设置失败:', error); + } +} + +export async function loadWsAuthSetting() { + try { + const config = await this.getConfig(); + if (config['ws-auth'] !== undefined) { + const wsAuthToggle = document.getElementById('ws-auth-toggle'); + if (wsAuthToggle) { + wsAuthToggle.checked = config['ws-auth']; + } + } + } catch (error) { + console.error('加载 WebSocket 鉴权设置失败:', error); + } +} + +export async function updateUsageStatisticsEnabled(enabled) { + try { + await this.makeRequest('/usage-statistics-enabled', { + method: 'PUT', + body: JSON.stringify({ value: enabled }) + }); + this.clearCache(); + this.showNotification(i18n.t('notification.usage_statistics_updated'), 'success'); + } catch (error) { + this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error'); + const usageToggle = document.getElementById('usage-statistics-enabled-toggle'); + if (usageToggle) { + usageToggle.checked = !enabled; + } + } +} + +export async function updateRequestLog(enabled) { + try { + await this.makeRequest('/request-log', { + method: 'PUT', + body: JSON.stringify({ value: enabled }) + }); + this.clearCache(); + this.showNotification(i18n.t('notification.request_log_updated'), 'success'); + } catch (error) { + this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error'); + const requestLogToggle = document.getElementById('request-log-toggle'); + if (requestLogToggle) { + requestLogToggle.checked = !enabled; + } + } +} + +export async function updateWsAuth(enabled) { + try { + await this.makeRequest('/ws-auth', { + method: 'PUT', + body: JSON.stringify({ value: enabled }) + }); + this.clearCache(); + this.showNotification(i18n.t('notification.ws_auth_updated'), 'success'); + } catch (error) { + this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error'); + const wsAuthToggle = document.getElementById('ws-auth-toggle'); + if (wsAuthToggle) { + wsAuthToggle.checked = !enabled; + } + } +} + +export async function updateLoggingToFile(enabled) { + try { + await this.makeRequest('/logging-to-file', { + method: 'PUT', + body: JSON.stringify({ value: enabled }) + }); + this.clearCache(); + this.showNotification(i18n.t('notification.logging_to_file_updated'), 'success'); + // 显示或隐藏日志查看栏目 + this.toggleLogsNavItem(enabled); + // 如果启用了日志记录,自动刷新日志 + if (enabled) { + setTimeout(() => this.refreshLogs(), 500); + } + } catch (error) { + this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error'); + const loggingToggle = document.getElementById('logging-to-file-toggle'); + if (loggingToggle) { + loggingToggle.checked = !enabled; + } + } +} + +export async function updateSwitchProject(enabled) { + try { + await this.makeRequest('/quota-exceeded/switch-project', { + method: 'PUT', + body: JSON.stringify({ value: enabled }) + }); + this.clearCache(); // 清除缓存 + this.showNotification(i18n.t('notification.quota_switch_project_updated'), 'success'); + } catch (error) { + this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error'); + document.getElementById('switch-project-toggle').checked = !enabled; + } +} + +export async function updateSwitchPreviewModel(enabled) { + try { + await this.makeRequest('/quota-exceeded/switch-preview-model', { + method: 'PUT', + body: JSON.stringify({ value: enabled }) + }); + this.clearCache(); // 清除缓存 + this.showNotification(i18n.t('notification.quota_switch_preview_updated'), 'success'); + } catch (error) { + this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error'); + document.getElementById('switch-preview-model-toggle').checked = !enabled; + } +} + +export const settingsModule = { + updateDebug, + updateProxyUrl, + clearProxyUrl, + updateRequestRetry, + loadDebugSettings, + loadProxySettings, + loadRetrySettings, + loadQuotaSettings, + loadUsageStatisticsSettings, + loadRequestLogSetting, + loadWsAuthSetting, + updateUsageStatisticsEnabled, + updateRequestLog, + updateWsAuth, + updateLoggingToFile, + updateSwitchProject, + updateSwitchPreviewModel +}; diff --git a/src/modules/theme.js b/src/modules/theme.js new file mode 100644 index 0000000..5b3743b --- /dev/null +++ b/src/modules/theme.js @@ -0,0 +1,73 @@ +export const themeModule = { + initializeTheme() { + const savedTheme = localStorage.getItem('preferredTheme'); + if (savedTheme && ['light', 'dark'].includes(savedTheme)) { + this.currentTheme = savedTheme; + } else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { + this.currentTheme = 'dark'; + } else { + this.currentTheme = 'light'; + } + + this.applyTheme(this.currentTheme); + this.updateThemeButtons(); + + if (window.matchMedia) { + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => { + if (!localStorage.getItem('preferredTheme')) { + this.currentTheme = e.matches ? 'dark' : 'light'; + this.applyTheme(this.currentTheme); + this.updateThemeButtons(); + } + }); + } + }, + + applyTheme(theme) { + if (theme === 'dark') { + document.documentElement.setAttribute('data-theme', 'dark'); + } else { + document.documentElement.removeAttribute('data-theme'); + } + this.currentTheme = theme; + }, + + toggleTheme() { + const newTheme = this.currentTheme === 'light' ? 'dark' : 'light'; + this.applyTheme(newTheme); + this.updateThemeButtons(); + localStorage.setItem('preferredTheme', newTheme); + }, + + updateThemeButtons() { + const loginThemeBtn = document.getElementById('theme-toggle'); + const mainThemeBtn = document.getElementById('theme-toggle-main'); + + const updateButton = (btn) => { + if (!btn) return; + const icon = btn.querySelector('i'); + if (this.currentTheme === 'dark') { + icon.className = 'fas fa-sun'; + btn.title = i18n.t('theme.switch_to_light'); + } else { + icon.className = 'fas fa-moon'; + btn.title = i18n.t('theme.switch_to_dark'); + } + }; + + updateButton(loginThemeBtn); + updateButton(mainThemeBtn); + }, + + setupThemeSwitcher() { + const loginToggle = document.getElementById('theme-toggle'); + const mainToggle = document.getElementById('theme-toggle-main'); + + if (loginToggle) { + loginToggle.addEventListener('click', () => this.toggleTheme()); + } + if (mainToggle) { + mainToggle.addEventListener('click', () => this.toggleTheme()); + } + } +}; diff --git a/src/modules/usage.js b/src/modules/usage.js new file mode 100644 index 0000000..6ebaa35 --- /dev/null +++ b/src/modules/usage.js @@ -0,0 +1,682 @@ +// 获取API密钥的统计信息 +export async function getKeyStats() { + try { + const response = await this.makeRequest('/usage'); + const usage = response?.usage || null; + + if (!usage) { + return {}; + } + + const sourceStats = {}; + const apis = usage.apis || {}; + + Object.values(apis).forEach(apiEntry => { + const models = apiEntry.models || {}; + + Object.values(models).forEach(modelEntry => { + const details = modelEntry.details || []; + + details.forEach(detail => { + const source = detail.source; + if (!source) return; + + if (!sourceStats[source]) { + sourceStats[source] = { + success: 0, + failure: 0 + }; + } + + const isFailed = detail.failed === true; + if (isFailed) { + sourceStats[source].failure += 1; + } else { + sourceStats[source].success += 1; + } + }); + }); + }); + + return sourceStats; + } catch (error) { + console.error('获取统计信息失败:', error); + return {}; + } +} + +// 加载使用统计 +export async function loadUsageStats(usageData = null) { + try { + let usage = usageData; + // 如果没有传入usage数据,则调用API获取 + if (!usage) { + const response = await this.makeRequest('/usage'); + usage = response?.usage || null; + } + this.currentUsageData = usage; + + if (!usage) { + throw new Error('usage payload missing'); + } + + // 更新概览卡片 + this.updateUsageOverview(usage); + this.updateModelFilterOptions(usage); + + // 读取当前图表周期 + const requestsHourActive = document.getElementById('requests-hour-btn')?.classList.contains('active'); + const tokensHourActive = document.getElementById('tokens-hour-btn')?.classList.contains('active'); + const requestsPeriod = requestsHourActive ? 'hour' : 'day'; + const tokensPeriod = tokensHourActive ? 'hour' : 'day'; + + // 初始化图表(使用当前周期) + this.initializeRequestsChart(requestsPeriod); + this.initializeTokensChart(tokensPeriod); + + // 更新API详细统计表格 + this.updateApiStatsTable(usage); + + } catch (error) { + console.error('加载使用统计失败:', error); + this.currentUsageData = null; + this.updateModelFilterOptions(null); + + // 清空概览数据 + ['total-requests', 'success-requests', 'failed-requests', 'total-tokens'].forEach(id => { + const el = document.getElementById(id); + if (el) el.textContent = '-'; + }); + + // 清空图表 + if (this.requestsChart) { + this.requestsChart.destroy(); + this.requestsChart = null; + } + if (this.tokensChart) { + this.tokensChart.destroy(); + this.tokensChart = null; + } + + const tableElement = document.getElementById('api-stats-table'); + if (tableElement) { + tableElement.innerHTML = `
${i18n.t('usage_stats.loading_error')}: ${error.message}
`; + } + } +} + +// 更新使用统计概览 +export function updateUsageOverview(data) { + const safeData = data || {}; + document.getElementById('total-requests').textContent = safeData.total_requests ?? 0; + document.getElementById('success-requests').textContent = safeData.success_count ?? 0; + document.getElementById('failed-requests').textContent = safeData.failure_count ?? 0; + document.getElementById('total-tokens').textContent = safeData.total_tokens ?? 0; +} + +export function getModelNamesFromUsage(usage) { + if (!usage) { + return []; + } + const apis = usage.apis || {}; + const names = new Set(); + Object.values(apis).forEach(apiEntry => { + const models = apiEntry.models || {}; + Object.keys(models).forEach(modelName => { + if (modelName) { + names.add(modelName); + } + }); + }); + return Array.from(names).sort((a, b) => a.localeCompare(b)); +} + +export function updateModelFilterOptions(usage) { + const select = document.getElementById('model-filter-select'); + if (!select) { + return; + } + + const modelNames = this.getModelNamesFromUsage(usage); + const previousSelection = this.currentModelFilter || 'all'; + const fragment = document.createDocumentFragment(); + + const allOption = document.createElement('option'); + allOption.value = 'all'; + allOption.textContent = i18n.t('usage_stats.model_filter_all'); + fragment.appendChild(allOption); + + modelNames.forEach(name => { + const option = document.createElement('option'); + option.value = name; + option.textContent = name; + fragment.appendChild(option); + }); + + select.innerHTML = ''; + select.appendChild(fragment); + + let nextSelection = previousSelection; + if (nextSelection !== 'all' && !modelNames.includes(nextSelection)) { + nextSelection = 'all'; + } + this.currentModelFilter = nextSelection; + select.value = nextSelection; + select.disabled = modelNames.length === 0; +} + +export function handleModelFilterChange(value) { + const normalized = value || 'all'; + if (this.currentModelFilter === normalized) { + return; + } + this.currentModelFilter = normalized; + this.refreshChartsForModelFilter(); +} + +export function refreshChartsForModelFilter() { + if (!this.currentUsageData) { + return; + } + const requestsHourActive = document.getElementById('requests-hour-btn')?.classList.contains('active'); + const tokensHourActive = document.getElementById('tokens-hour-btn')?.classList.contains('active'); + const requestsPeriod = requestsHourActive ? 'hour' : 'day'; + const tokensPeriod = tokensHourActive ? 'hour' : 'day'; + + if (this.requestsChart) { + this.requestsChart.data = this.getRequestsChartData(requestsPeriod); + this.requestsChart.update(); + } else { + this.initializeRequestsChart(requestsPeriod); + } + + if (this.tokensChart) { + this.tokensChart.data = this.getTokensChartData(tokensPeriod); + this.tokensChart.update(); + } else { + this.initializeTokensChart(tokensPeriod); + } +} + +// 收集所有请求明细,供图表等复用 +export function collectUsageDetailsFromUsage(usage) { + if (!usage) { + return []; + } + const apis = usage.apis || {}; + const details = []; + Object.values(apis).forEach(apiEntry => { + const models = apiEntry.models || {}; + Object.entries(models).forEach(([modelName, modelEntry]) => { + const modelDetails = Array.isArray(modelEntry.details) ? modelEntry.details : []; + modelDetails.forEach(detail => { + if (detail && detail.timestamp) { + details.push({ + ...detail, + __modelName: modelName + }); + } + }); + }); + }); + return details; +} + +export function collectUsageDetails() { + return this.collectUsageDetailsFromUsage(this.currentUsageData); +} + +// 构建最近24小时的统计序列 +export function buildRecentHourlySeries(metric = 'requests') { + const details = this.collectUsageDetails(); + if (!details.length) { + return null; + } + + const modelFilter = this.currentModelFilter || 'all'; + const hourMs = 60 * 60 * 1000; + const now = new Date(); + const currentHour = new Date(now); + currentHour.setMinutes(0, 0, 0); + + const earliestBucket = new Date(currentHour); + earliestBucket.setHours(earliestBucket.getHours() - 23); + const earliestTime = earliestBucket.getTime(); + const labels = []; + const values = new Array(24).fill(0); + + for (let i = 0; i < 24; i++) { + const bucketStart = earliestTime + i * hourMs; + labels.push(this.formatHourLabel(new Date(bucketStart))); + } + + const latestBucketStart = earliestTime + (values.length - 1) * hourMs; + let hasMatch = false; + + details.forEach(detail => { + if (modelFilter !== 'all' && detail.__modelName !== modelFilter) { + return; + } + const timestamp = Date.parse(detail.timestamp); + if (Number.isNaN(timestamp)) { + return; + } + + const normalized = new Date(timestamp); + normalized.setMinutes(0, 0, 0); + const bucketStart = normalized.getTime(); + if (bucketStart < earliestTime || bucketStart > latestBucketStart) { + return; + } + + const bucketIndex = Math.floor((bucketStart - earliestTime) / hourMs); + if (bucketIndex < 0 || bucketIndex >= values.length) { + return; + } + + if (metric === 'tokens') { + values[bucketIndex] += this.extractTotalTokens(detail); + } else { + values[bucketIndex] += 1; + } + hasMatch = true; + }); + + if (!hasMatch) { + return modelFilter === 'all' ? null : { labels, values }; + } + + return { labels, values }; +} + +export function buildDailySeries(metric = 'requests') { + const details = this.collectUsageDetails(); + if (!details.length) { + return null; + } + + const modelFilter = this.currentModelFilter || 'all'; + const dayBuckets = {}; + let hasMatch = false; + + details.forEach(detail => { + if (modelFilter !== 'all' && detail.__modelName !== modelFilter) { + return; + } + const timestamp = Date.parse(detail.timestamp); + if (Number.isNaN(timestamp)) { + return; + } + const dayLabel = this.formatDayLabel(new Date(timestamp)); + if (!dayLabel) { + return; + } + + if (!dayBuckets[dayLabel]) { + dayBuckets[dayLabel] = 0; + } + if (metric === 'tokens') { + dayBuckets[dayLabel] += this.extractTotalTokens(detail); + } else { + dayBuckets[dayLabel] += 1; + } + hasMatch = true; + }); + + if (!hasMatch) { + return modelFilter === 'all' ? null : { labels: [], values: [] }; + } + + const labels = Object.keys(dayBuckets).sort(); + const values = labels.map(label => dayBuckets[label] || 0); + return { labels, values }; +} + +// 统一格式化小时标签 +export function formatHourLabel(date) { + if (!(date instanceof Date)) { + return ''; + } + const month = (date.getMonth() + 1).toString().padStart(2, '0'); + const day = date.getDate().toString().padStart(2, '0'); + const hour = date.getHours().toString().padStart(2, '0'); + return `${month}-${day} ${hour}:00`; +} + +export function formatDayLabel(date) { + if (!(date instanceof Date)) { + return ''; + } + const year = date.getFullYear(); + const month = (date.getMonth() + 1).toString().padStart(2, '0'); + const day = date.getDate().toString().padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +export function extractTotalTokens(detail) { + const tokens = detail?.tokens || {}; + if (typeof tokens.total_tokens === 'number') { + return tokens.total_tokens; + } + const tokenKeys = ['input_tokens', 'output_tokens', 'reasoning_tokens', 'cached_tokens']; + return tokenKeys.reduce((sum, key) => { + const value = tokens[key]; + return sum + (typeof value === 'number' ? value : 0); + }, 0); +} + +// 初始化图表 +export function initializeCharts() { + const requestsHourActive = document.getElementById('requests-hour-btn')?.classList.contains('active'); + const tokensHourActive = document.getElementById('tokens-hour-btn')?.classList.contains('active'); + this.initializeRequestsChart(requestsHourActive ? 'hour' : 'day'); + this.initializeTokensChart(tokensHourActive ? 'hour' : 'day'); +} + +// 初始化请求趋势图表 +export function initializeRequestsChart(period = 'day') { + const ctx = document.getElementById('requests-chart'); + if (!ctx) return; + + // 销毁现有图表 + if (this.requestsChart) { + this.requestsChart.destroy(); + } + + const data = this.getRequestsChartData(period); + + this.requestsChart = new Chart(ctx, { + type: 'line', + data: data, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + display: false + } + }, + scales: { + x: { + title: { + display: true, + text: i18n.t(period === 'hour' ? 'usage_stats.by_hour' : 'usage_stats.by_day') + } + }, + y: { + beginAtZero: true, + title: { + display: true, + text: i18n.t('usage_stats.requests_count') + } + } + }, + elements: { + line: { + borderColor: '#3b82f6', + backgroundColor: 'rgba(59, 130, 246, 0.1)', + fill: true, + tension: 0.4 + }, + point: { + backgroundColor: '#3b82f6', + borderColor: '#ffffff', + borderWidth: 2, + radius: 4 + } + } + } + }); +} + +// 初始化Token使用趋势图表 +export function initializeTokensChart(period = 'day') { + const ctx = document.getElementById('tokens-chart'); + if (!ctx) return; + + // 销毁现有图表 + if (this.tokensChart) { + this.tokensChart.destroy(); + } + + const data = this.getTokensChartData(period); + + this.tokensChart = new Chart(ctx, { + type: 'line', + data: data, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + display: false + } + }, + scales: { + x: { + title: { + display: true, + text: i18n.t(period === 'hour' ? 'usage_stats.by_hour' : 'usage_stats.by_day') + } + }, + y: { + beginAtZero: true, + title: { + display: true, + text: i18n.t('usage_stats.tokens_count') + } + } + }, + elements: { + line: { + borderColor: '#10b981', + backgroundColor: 'rgba(16, 185, 129, 0.1)', + fill: true, + tension: 0.4 + }, + point: { + backgroundColor: '#10b981', + borderColor: '#ffffff', + borderWidth: 2, + radius: 4 + } + } + } + }); +} + +// 获取请求图表数据 +export function getRequestsChartData(period) { + if (!this.currentUsageData) { + return { labels: [], datasets: [{ data: [] }] }; + } + + let dataSource, labels, values; + + if (period === 'hour') { + const hourlySeries = this.buildRecentHourlySeries('requests'); + if (hourlySeries) { + labels = hourlySeries.labels; + values = hourlySeries.values; + } else { + dataSource = this.currentUsageData.requests_by_hour || {}; + labels = Array.from({ length: 24 }, (_, i) => i.toString().padStart(2, '0')); + values = labels.map(hour => dataSource[hour] || 0); + } + } else { + const dailySeries = this.buildDailySeries('requests'); + if (dailySeries) { + labels = dailySeries.labels; + values = dailySeries.values; + } else { + dataSource = this.currentUsageData.requests_by_day || {}; + labels = Object.keys(dataSource).sort(); + values = labels.map(day => dataSource[day] || 0); + } + } + + return { + labels: labels, + datasets: [{ + data: values + }] + }; +} + +// 获取Token图表数据 +export function getTokensChartData(period) { + if (!this.currentUsageData) { + return { labels: [], datasets: [{ data: [] }] }; + } + + let dataSource, labels, values; + + if (period === 'hour') { + const hourlySeries = this.buildRecentHourlySeries('tokens'); + if (hourlySeries) { + labels = hourlySeries.labels; + values = hourlySeries.values; + } else { + dataSource = this.currentUsageData.tokens_by_hour || {}; + labels = Array.from({ length: 24 }, (_, i) => i.toString().padStart(2, '0')); + values = labels.map(hour => dataSource[hour] || 0); + } + } else { + const dailySeries = this.buildDailySeries('tokens'); + if (dailySeries) { + labels = dailySeries.labels; + values = dailySeries.values; + } else { + dataSource = this.currentUsageData.tokens_by_day || {}; + labels = Object.keys(dataSource).sort(); + values = labels.map(day => dataSource[day] || 0); + } + } + + return { + labels: labels, + datasets: [{ + data: values + }] + }; +} + +// 切换请求图表时间周期 +export function switchRequestsPeriod(period) { + // 更新按钮状态 + document.getElementById('requests-hour-btn').classList.toggle('active', period === 'hour'); + document.getElementById('requests-day-btn').classList.toggle('active', period === 'day'); + + // 更新图表数据 + if (this.requestsChart) { + const newData = this.getRequestsChartData(period); + this.requestsChart.data = newData; + this.requestsChart.options.scales.x.title.text = i18n.t(period === 'hour' ? 'usage_stats.by_hour' : 'usage_stats.by_day'); + this.requestsChart.update(); + } +} + +// 切换Token图表时间周期 +export function switchTokensPeriod(period) { + // 更新按钮状态 + document.getElementById('tokens-hour-btn').classList.toggle('active', period === 'hour'); + document.getElementById('tokens-day-btn').classList.toggle('active', period === 'day'); + + // 更新图表数据 + if (this.tokensChart) { + const newData = this.getTokensChartData(period); + this.tokensChart.data = newData; + this.tokensChart.options.scales.x.title.text = i18n.t(period === 'hour' ? 'usage_stats.by_hour' : 'usage_stats.by_day'); + this.tokensChart.update(); + } +} + +// 更新API详细统计表格 +export function updateApiStatsTable(data) { + const container = document.getElementById('api-stats-table'); + if (!container) return; + + const apis = data.apis || {}; + + if (Object.keys(apis).length === 0) { + container.innerHTML = `
${i18n.t('usage_stats.no_data')}
`; + return; + } + + let tableHtml = ` + + + + + + + + + + + + `; + + Object.entries(apis).forEach(([endpoint, apiData]) => { + const totalRequests = apiData.total_requests || 0; + const successCount = apiData.success_count ?? null; + const successRate = successCount !== null && totalRequests > 0 + ? Math.round((successCount / totalRequests) * 100) + : null; + + // 构建模型详情 + let modelsHtml = ''; + if (apiData.models && Object.keys(apiData.models).length > 0) { + modelsHtml = '
'; + Object.entries(apiData.models).forEach(([modelName, modelData]) => { + const modelRequests = modelData.total_requests ?? 0; + const modelTokens = modelData.total_tokens ?? 0; + modelsHtml += ` +
+ ${modelName} + ${modelRequests} 请求 / ${modelTokens} tokens +
+ `; + }); + modelsHtml += '
'; + } + + tableHtml += ` + + + + + + + + `; + }); + + tableHtml += '
${i18n.t('usage_stats.api_endpoint')}${i18n.t('usage_stats.requests_count')}${i18n.t('usage_stats.tokens_count')}${i18n.t('usage_stats.success_rate')}${i18n.t('usage_stats.models')}
${endpoint}${totalRequests}${apiData.total_tokens || 0}${successRate !== null ? successRate + '%' : '-'}${modelsHtml || '-'}
'; + container.innerHTML = tableHtml; +} + +export const usageModule = { + getKeyStats, + loadUsageStats, + updateUsageOverview, + getModelNamesFromUsage, + updateModelFilterOptions, + handleModelFilterChange, + refreshChartsForModelFilter, + collectUsageDetailsFromUsage, + collectUsageDetails, + buildRecentHourlySeries, + buildDailySeries, + formatHourLabel, + formatDayLabel, + extractTotalTokens, + initializeCharts, + initializeRequestsChart, + initializeTokensChart, + getRequestsChartData, + getTokensChartData, + switchRequestsPeriod, + switchTokensPeriod, + updateApiStatsTable +};