diff --git a/app.js b/app.js index 70b8d16..293b1d1 100644 --- a/app.js +++ b/app.js @@ -24,7 +24,8 @@ import { MIN_AUTH_FILES_PAGE_SIZE, MAX_AUTH_FILES_PAGE_SIZE, OAUTH_CARD_IDS, - STORAGE_KEY_AUTH_FILES_PAGE_SIZE + STORAGE_KEY_AUTH_FILES_PAGE_SIZE, + STATUS_UPDATE_INTERVAL_MS } from './src/utils/constants.js'; // 核心服务导入 @@ -41,9 +42,9 @@ class CLIProxyManager { this.isConnected = false; this.isLoggedIn = false; - // 配置缓存 - this.configCache = null; - this.cacheTimestamp = null; + // 配置缓存 - 改为分段缓存 + this.configCache = {}; // 改为对象,按配置段缓存 + this.cacheTimestamps = {}; // 每个配置段的时间戳 this.cacheExpiry = CACHE_EXPIRY_MS; // 状态更新定时器 @@ -708,37 +709,91 @@ class CLIProxyManager { } // 检查缓存是否有效 - isCacheValid() { - if (!this.configCache || !this.cacheTimestamp) { + isCacheValid(section = null) { + if (section) { + // 检查特定配置段的缓存 + // 注意:配置值可能是 false、0、'' 等 falsy 值,不能用 ! 判断 + if (!(section in this.configCache) || !(section in this.cacheTimestamps)) { + return false; + } + return (Date.now() - this.cacheTimestamps[section]) < this.cacheExpiry; + } + // 检查全局缓存(兼容旧代码) + if (!this.configCache['__full__'] || !this.cacheTimestamps['__full__']) { return false; } - return (Date.now() - this.cacheTimestamp) < this.cacheExpiry; + return (Date.now() - this.cacheTimestamps['__full__']) < this.cacheExpiry; } - // 获取配置(优先使用缓存) - async getConfig(forceRefresh = false) { - if (!forceRefresh && this.isCacheValid()) { - this.updateConnectionStatus(); // 更新状态显示 - return this.configCache; + // 获取配置(优先使用缓存,支持按段获取) + async getConfig(section = null, forceRefresh = false) { + const now = Date.now(); + + // 如果请求特定配置段且该段缓存有效 + if (section && !forceRefresh && this.isCacheValid(section)) { + this.updateConnectionStatus(); + return this.configCache[section]; + } + + // 如果请求全部配置且全局缓存有效 + if (!section && !forceRefresh && this.isCacheValid()) { + this.updateConnectionStatus(); + return this.configCache['__full__']; } try { const config = await this.makeRequest('/config'); - this.configCache = config; - this.cacheTimestamp = Date.now(); - this.updateConnectionStatus(); // 更新状态显示 - return config; + + if (section) { + // 缓存特定配置段 + this.configCache[section] = config[section]; + this.cacheTimestamps[section] = now; + // 同时更新全局缓存中的这一段 + if (this.configCache['__full__']) { + this.configCache['__full__'][section] = config[section]; + } else { + // 如果全局缓存不存在,也创建它 + this.configCache['__full__'] = config; + this.cacheTimestamps['__full__'] = now; + } + this.updateConnectionStatus(); + return config[section]; + } else { + // 缓存全部配置 + this.configCache['__full__'] = config; + this.cacheTimestamps['__full__'] = now; + + // 同时缓存各个配置段 + Object.keys(config).forEach(key => { + this.configCache[key] = config[key]; + this.cacheTimestamps[key] = now; + }); + + this.updateConnectionStatus(); + return config; + } } catch (error) { console.error('获取配置失败:', error); throw error; } } - // 清除缓存 - clearCache() { - this.configCache = null; - this.cacheTimestamp = null; - this.configYamlCache = ''; + // 清除缓存(支持清除特定配置段) + clearCache(section = null) { + if (section) { + // 清除特定配置段的缓存 + delete this.configCache[section]; + delete this.cacheTimestamps[section]; + // 同时清除全局缓存中的这一段 + if (this.configCache['__full__']) { + delete this.configCache['__full__'][section]; + } + } else { + // 清除所有缓存 + this.configCache = {}; + this.cacheTimestamps = {}; + this.configYamlCache = ''; + } } // 启动状态更新定时器 @@ -750,7 +805,7 @@ class CLIProxyManager { if (this.isConnected) { this.updateConnectionStatus(); } - }, 1000); // 每秒更新一次 + }, STATUS_UPDATE_INTERVAL_MS); } // 停止状态更新定时器 @@ -766,7 +821,8 @@ class CLIProxyManager { try { console.log(i18n.t('system_info.real_time_data')); // 使用新的 /config 端点一次性获取所有配置 - const config = await this.getConfig(forceRefresh); + // 注意:getConfig(section, forceRefresh),不传 section 表示获取全部 + const config = await this.getConfig(null, forceRefresh); // 获取一次usage统计数据,供渲染函数和loadUsageStats复用 let usageData = null; diff --git a/src/modules/settings.js b/src/modules/settings.js index 48bf65b..2197cc2 100644 --- a/src/modules/settings.js +++ b/src/modules/settings.js @@ -1,81 +1,106 @@ // 设置与开关相关方法模块 -// 注意:这些函数依赖于在 CLIProxyManager 实例上提供的 makeRequest/clearCache/showNotification 等基础能力 +// 注意:这些函数依赖于在 CLIProxyManager 实例上提供的 makeRequest/clearCache/showNotification/errorHandler 等基础能力 export async function updateDebug(enabled) { + const previousValue = !enabled; try { await this.makeRequest('/debug', { method: 'PUT', body: JSON.stringify({ value: enabled }) }); - this.clearCache(); // 清除缓存 + this.clearCache('debug'); // 仅清除 debug 配置段的缓存 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; + this.errorHandler.handleUpdateError( + error, + i18n.t('settings.debug_mode') || '调试模式', + () => document.getElementById('debug-toggle').checked = previousValue + ); } } export async function updateProxyUrl() { const proxyUrl = document.getElementById('proxy-url').value.trim(); + const previousValue = document.getElementById('proxy-url').getAttribute('data-previous-value') || ''; try { await this.makeRequest('/proxy-url', { method: 'PUT', body: JSON.stringify({ value: proxyUrl }) }); - this.clearCache(); // 清除缓存 + this.clearCache('proxy-url'); // 仅清除 proxy-url 配置段的缓存 + document.getElementById('proxy-url').setAttribute('data-previous-value', proxyUrl); this.showNotification(i18n.t('notification.proxy_updated'), 'success'); } catch (error) { - this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error'); + this.errorHandler.handleUpdateError( + error, + i18n.t('settings.proxy_url') || '代理设置', + () => document.getElementById('proxy-url').value = previousValue + ); } } export async function clearProxyUrl() { + const previousValue = document.getElementById('proxy-url').value; + try { await this.makeRequest('/proxy-url', { method: 'DELETE' }); document.getElementById('proxy-url').value = ''; - this.clearCache(); // 清除缓存 + document.getElementById('proxy-url').setAttribute('data-previous-value', ''); + this.clearCache('proxy-url'); // 仅清除 proxy-url 配置段的缓存 this.showNotification(i18n.t('notification.proxy_cleared'), 'success'); } catch (error) { - this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error'); + this.errorHandler.handleUpdateError( + error, + i18n.t('settings.proxy_url') || '代理设置', + () => document.getElementById('proxy-url').value = previousValue + ); } } export async function updateRequestRetry() { - const retryCount = parseInt(document.getElementById('request-retry').value); + const retryInput = document.getElementById('request-retry'); + const retryCount = parseInt(retryInput.value); + const previousValue = retryInput.getAttribute('data-previous-value') || '0'; try { await this.makeRequest('/request-retry', { method: 'PUT', body: JSON.stringify({ value: retryCount }) }); - this.clearCache(); // 清除缓存 + this.clearCache('request-retry'); // 仅清除 request-retry 配置段的缓存 + retryInput.setAttribute('data-previous-value', retryCount.toString()); this.showNotification(i18n.t('notification.retry_updated'), 'success'); } catch (error) { - this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error'); + this.errorHandler.handleUpdateError( + error, + i18n.t('settings.request_retry') || '重试设置', + () => retryInput.value = previousValue + ); } } export async function loadDebugSettings() { try { - const config = await this.getConfig(); - if (config.debug !== undefined) { - document.getElementById('debug-toggle').checked = config.debug; + const debugValue = await this.getConfig('debug'); // 仅获取 debug 配置段 + if (debugValue !== undefined) { + document.getElementById('debug-toggle').checked = debugValue; } } catch (error) { - console.error('加载调试设置失败:', error); + this.errorHandler.handleLoadError(error, i18n.t('settings.debug_mode') || '调试设置'); } } export async function loadProxySettings() { try { - const config = await this.getConfig(); - if (config['proxy-url'] !== undefined) { - document.getElementById('proxy-url').value = config['proxy-url'] || ''; + const proxyUrl = await this.getConfig('proxy-url'); // 仅获取 proxy-url 配置段 + const proxyInput = document.getElementById('proxy-url'); + if (proxyUrl !== undefined) { + proxyInput.value = proxyUrl || ''; + proxyInput.setAttribute('data-previous-value', proxyUrl || ''); } } catch (error) { - console.error('加载代理设置失败:', error); + this.errorHandler.handleLoadError(error, i18n.t('settings.proxy_settings') || '代理设置'); } } diff --git a/src/utils/dom.js b/src/utils/dom.js new file mode 100644 index 0000000..3fb22b5 --- /dev/null +++ b/src/utils/dom.js @@ -0,0 +1,279 @@ +/** + * DOM 操作工具函数模块 + * 提供高性能的 DOM 操作方法 + */ + +/** + * 批量渲染列表项,使用 DocumentFragment 减少重绘 + * @param {HTMLElement} container - 容器元素 + * @param {Array} items - 数据项数组 + * @param {Function} renderItemFn - 渲染单个项目的函数,返回 HTML 字符串或 Element + * @param {boolean} append - 是否追加模式(默认 false,清空后渲染) + * + * @example + * renderList(container, files, (file) => ` + *
${file.name}
+ * `); + */ +export function renderList(container, items, renderItemFn, append = false) { + if (!container) return; + + const fragment = document.createDocumentFragment(); + + items.forEach((item, index) => { + const rendered = renderItemFn(item, index); + + if (typeof rendered === 'string') { + // HTML 字符串,创建临时容器 + const temp = document.createElement('div'); + temp.innerHTML = rendered; + // 将所有子元素添加到 fragment + while (temp.firstChild) { + fragment.appendChild(temp.firstChild); + } + } else if (rendered instanceof HTMLElement) { + // DOM 元素,直接添加 + fragment.appendChild(rendered); + } + }); + + if (!append) { + container.innerHTML = ''; + } + container.appendChild(fragment); +} + +/** + * 创建 DOM 元素的快捷方法 + * @param {string} tag - 标签名 + * @param {Object} attrs - 属性对象 + * @param {string|Array} content - 内容(文本或子元素数组) + * @returns {HTMLElement} + * + * @example + * const div = createElement('div', { class: 'item', 'data-id': '123' }, 'Hello'); + * const ul = createElement('ul', {}, [ + * createElement('li', {}, 'Item 1'), + * createElement('li', {}, 'Item 2') + * ]); + */ +export function createElement(tag, attrs = {}, content = null) { + const element = document.createElement(tag); + + // 设置属性 + Object.keys(attrs).forEach(key => { + if (key === 'class') { + element.className = attrs[key]; + } else if (key === 'style' && typeof attrs[key] === 'object') { + Object.assign(element.style, attrs[key]); + } else if (key.startsWith('on') && typeof attrs[key] === 'function') { + const eventName = key.substring(2).toLowerCase(); + element.addEventListener(eventName, attrs[key]); + } else { + element.setAttribute(key, attrs[key]); + } + }); + + // 设置内容 + if (content !== null && content !== undefined) { + if (typeof content === 'string') { + element.textContent = content; + } else if (Array.isArray(content)) { + content.forEach(child => { + if (child instanceof HTMLElement) { + element.appendChild(child); + } + }); + } else if (content instanceof HTMLElement) { + element.appendChild(content); + } + } + + return element; +} + +/** + * 批量更新元素属性,减少重绘 + * @param {HTMLElement} element - 目标元素 + * @param {Object} updates - 更新对象 + * + * @example + * batchUpdate(element, { + * className: 'active', + * style: { color: 'red', fontSize: '16px' }, + * textContent: 'Updated' + * }); + */ +export function batchUpdate(element, updates) { + if (!element) return; + + // 使用 requestAnimationFrame 批量更新 + requestAnimationFrame(() => { + Object.keys(updates).forEach(key => { + if (key === 'style' && typeof updates[key] === 'object') { + Object.assign(element.style, updates[key]); + } else { + element[key] = updates[key]; + } + }); + }); +} + +/** + * 延迟渲染大列表,避免阻塞 UI + * @param {HTMLElement} container - 容器元素 + * @param {Array} items - 数据项数组 + * @param {Function} renderItemFn - 渲染函数 + * @param {number} batchSize - 每批渲染数量 + * @returns {Promise} 完成渲染的 Promise + * + * @example + * await renderListAsync(container, largeArray, renderItem, 50); + */ +export function renderListAsync(container, items, renderItemFn, batchSize = 50) { + return new Promise((resolve) => { + if (!container || !items.length) { + resolve(); + return; + } + + container.innerHTML = ''; + let index = 0; + + function renderBatch() { + const fragment = document.createDocumentFragment(); + const end = Math.min(index + batchSize, items.length); + + for (let i = index; i < end; i++) { + const rendered = renderItemFn(items[i], i); + if (typeof rendered === 'string') { + const temp = document.createElement('div'); + temp.innerHTML = rendered; + while (temp.firstChild) { + fragment.appendChild(temp.firstChild); + } + } else if (rendered instanceof HTMLElement) { + fragment.appendChild(rendered); + } + } + + container.appendChild(fragment); + index = end; + + if (index < items.length) { + requestAnimationFrame(renderBatch); + } else { + resolve(); + } + } + + requestAnimationFrame(renderBatch); + }); +} + +/** + * 虚拟滚动渲染(仅渲染可见区域) + * @param {Object} config - 配置对象 + * @param {HTMLElement} config.container - 容器元素 + * @param {Array} config.items - 数据项数组 + * @param {Function} config.renderItemFn - 渲染函数 + * @param {number} config.itemHeight - 每项高度(像素) + * @param {number} [config.overscan=5] - 额外渲染的项数(上下各) + * @returns {Object} 包含 update 和 destroy 方法的对象 + */ +export function createVirtualScroll({ container, items, renderItemFn, itemHeight, overscan = 5 }) { + if (!container) return { update: () => {}, destroy: () => {} }; + + const totalHeight = items.length * itemHeight; + const viewportHeight = container.clientHeight; + + // 创建占位容器 + const placeholder = document.createElement('div'); + placeholder.style.height = `${totalHeight}px`; + placeholder.style.position = 'relative'; + + const content = document.createElement('div'); + content.style.position = 'absolute'; + content.style.top = '0'; + content.style.width = '100%'; + + placeholder.appendChild(content); + container.innerHTML = ''; + container.appendChild(placeholder); + + function render() { + const scrollTop = container.scrollTop; + const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan); + const endIndex = Math.min( + items.length, + Math.ceil((scrollTop + viewportHeight) / itemHeight) + overscan + ); + + const fragment = document.createDocumentFragment(); + + for (let i = startIndex; i < endIndex; i++) { + const element = renderItemFn(items[i], i); + if (typeof element === 'string') { + const temp = document.createElement('div'); + temp.innerHTML = element; + while (temp.firstChild) { + fragment.appendChild(temp.firstChild); + } + } else if (element instanceof HTMLElement) { + fragment.appendChild(element); + } + } + + content.style.top = `${startIndex * itemHeight}px`; + content.innerHTML = ''; + content.appendChild(fragment); + } + + const handleScroll = () => requestAnimationFrame(render); + container.addEventListener('scroll', handleScroll); + + // 初始渲染 + render(); + + return { + update: (newItems) => { + items = newItems; + placeholder.style.height = `${newItems.length * itemHeight}px`; + render(); + }, + destroy: () => { + container.removeEventListener('scroll', handleScroll); + } + }; +} + +/** + * 防抖包装器(用于搜索、输入等) + * @param {Function} fn - 要防抖的函数 + * @param {number} delay - 延迟时间(毫秒) + * @returns {Function} 防抖后的函数 + */ +export function debounce(fn, delay = 300) { + let timer; + return function (...args) { + clearTimeout(timer); + timer = setTimeout(() => fn.apply(this, args), delay); + }; +} + +/** + * 节流包装器(用于滚动、resize 等) + * @param {Function} fn - 要节流的函数 + * @param {number} limit - 限制时间(毫秒) + * @returns {Function} 节流后的函数 + */ +export function throttle(fn, limit = 100) { + let inThrottle; + return function (...args) { + if (!inThrottle) { + fn.apply(this, args); + inThrottle = true; + setTimeout(() => inThrottle = false, limit); + } + }; +}