From f82bcef9900d147948a5becb891881d5b5b979fb Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Mon, 17 Nov 2025 12:06:36 +0800 Subject: [PATCH] refactor(app): centralize UI constants and error handling --- app.js | 61 ++++---- build.cjs | 133 +++++++++-------- src/core/error-handler.js | 231 ++++++++++++++++++++++++++++++ src/modules/ai-providers.js | 2 +- src/modules/api-keys.js | 42 +----- src/utils/array.js | 172 ++++++++++++++++++++++ src/utils/constants.js | 274 ++++++++++++++++++++++++++++++++++++ src/utils/html.js | 62 ++++++++ src/utils/string.js | 116 +++++++++++++++ 9 files changed, 970 insertions(+), 123 deletions(-) create mode 100644 src/core/error-handler.js create mode 100644 src/utils/array.js create mode 100644 src/utils/constants.js create mode 100644 src/utils/html.js create mode 100644 src/utils/string.js diff --git a/app.js b/app.js index 5c04af8..70b8d16 100644 --- a/app.js +++ b/app.js @@ -1,3 +1,4 @@ +// 模块导入 import { themeModule } from './src/modules/theme.js'; import { navigationModule } from './src/modules/navigation.js'; import { languageModule } from './src/modules/language.js'; @@ -11,6 +12,24 @@ import { usageModule } from './src/modules/usage.js'; import { settingsModule } from './src/modules/settings.js'; import { aiProvidersModule } from './src/modules/ai-providers.js'; +// 工具函数导入 +import { escapeHtml } from './src/utils/html.js'; +import { maskApiKey } from './src/utils/string.js'; +import { normalizeArrayResponse } from './src/utils/array.js'; +import { + CACHE_EXPIRY_MS, + MAX_LOG_LINES, + DEFAULT_API_PORT, + DEFAULT_AUTH_FILES_PAGE_SIZE, + MIN_AUTH_FILES_PAGE_SIZE, + MAX_AUTH_FILES_PAGE_SIZE, + OAUTH_CARD_IDS, + STORAGE_KEY_AUTH_FILES_PAGE_SIZE +} from './src/utils/constants.js'; + +// 核心服务导入 +import { createErrorHandler } from './src/core/error-handler.js'; + // CLI Proxy API 管理界面 JavaScript class CLIProxyManager { constructor() { @@ -25,7 +44,7 @@ class CLIProxyManager { // 配置缓存 this.configCache = null; this.cacheTimestamp = null; - this.cacheExpiry = 30000; // 30秒缓存过期时间 + this.cacheExpiry = CACHE_EXPIRY_MS; // 状态更新定时器 this.statusUpdateTimer = null; @@ -35,7 +54,7 @@ class CLIProxyManager { // 当前展示的日志行 this.displayedLogLines = []; - this.maxDisplayLogLines = 10000; + this.maxDisplayLogLines = MAX_LOG_LINES; // 日志时间戳(用于增量加载) this.latestLogTimestamp = null; @@ -44,13 +63,13 @@ class CLIProxyManager { this.currentAuthFileFilter = 'all'; this.cachedAuthFiles = []; this.authFilesPagination = { - pageSize: 9, + pageSize: DEFAULT_AUTH_FILES_PAGE_SIZE, currentPage: 1, totalPages: 1 }; this.authFileStatsCache = {}; this.authFileSearchQuery = ''; - this.authFilesPageSizeKey = 'authFilesPageSize'; + this.authFilesPageSizeKey = STORAGE_KEY_AUTH_FILES_PAGE_SIZE; this.loadAuthFilePreferences(); // Vertex AI credential import state @@ -76,6 +95,9 @@ class CLIProxyManager { this.lastConfigFetchUrl = null; this.lastEditorConnectionState = null; + // 初始化错误处理器 + this.errorHandler = createErrorHandler((message, type) => this.showNotification(message, type)); + this.init(); } @@ -94,9 +116,9 @@ class CLIProxyManager { } normalizeAuthFilesPageSize(value) { - const defaultSize = 9; - const minSize = 3; - const maxSize = 60; + const defaultSize = DEFAULT_AUTH_FILES_PAGE_SIZE; + const minSize = MIN_AUTH_FILES_PAGE_SIZE; + const maxSize = MAX_AUTH_FILES_PAGE_SIZE; const parsed = parseInt(value, 10); if (!Number.isFinite(parsed) || parsed <= 0) { return defaultSize; @@ -135,15 +157,7 @@ class CLIProxyManager { if (!isLocalhost) { // 隐藏所有 OAuth 登录卡片 - const oauthCards = [ - 'codex-oauth-card', - 'anthropic-oauth-card', - 'gemini-cli-oauth-card', - 'qwen-oauth-card', - 'iflow-oauth-card' - ]; - - oauthCards.forEach(cardId => { + OAUTH_CARD_IDS.forEach(cardId => { const card = document.getElementById(cardId); if (card) { card.style.display = 'none'; @@ -894,14 +908,6 @@ class CLIProxyManager { await this.renderOpenAIProviders(Array.isArray(config['openai-compatibility']) ? config['openai-compatibility'] : [], keyStats); } - // HTML转义工具函数 - escapeHtml(text) { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; - } - - // 显示添加API密钥模态框 showAddApiKeyModal() { const modal = document.getElementById('modal'); @@ -1041,7 +1047,7 @@ class CLIProxyManager { return this.normalizeBase(`${protocol}//${hostname}${normalizedPort}`); } catch (error) { console.warn('无法从当前地址检测 API 基础地址,使用默认设置', error); - return this.normalizeBase(this.apiBase || 'http://localhost:8317'); + return this.normalizeBase(this.apiBase || `http://localhost:${DEFAULT_API_PORT}`); } } @@ -1156,6 +1162,11 @@ Object.assign( aiProvidersModule ); +// 将工具函数绑定到原型上,供模块使用 +CLIProxyManager.prototype.escapeHtml = escapeHtml; +CLIProxyManager.prototype.maskApiKey = maskApiKey; +CLIProxyManager.prototype.normalizeArrayResponse = normalizeArrayResponse; + // 全局管理器实例 let manager; diff --git a/build.cjs b/build.cjs index 2819b3e..702b064 100644 --- a/build.cjs +++ 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,44 +78,63 @@ function getVersion() { return 'v0.0.0-dev'; } -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 ensureDistDir() { + if (fs.existsSync(distDir)) { + fs.rmSync(distDir, { recursive: true, force: true }); + } + fs.mkdirSync(distDir); +} + +// 匹配各种 import 语句 +const importRegex = /import\s+(?:{[^}]*}|[\w*\s,{}]+)\s+from\s+['"]([^'"]+)['"];?/gm; +// 匹配 export 关键字(包括 export const, export function, export class, export async function 等) +const exportRegex = /^export\s+(?=const|let|var|function|class|default|async)/gm; +// 匹配单独的 export {} 或 export { ... } from '...' +const exportBraceRegex = /^export\s*{[^}]*}\s*(?:from\s+['"][^'"]+['"];?)?$/gm; + +function bundleApp(entryPath) { + const visited = new Set(); + const modules = []; + + function inlineFile(filePath) { + let content = readFile(filePath); + const dir = path.dirname(filePath); + + // 收集所有 import 语句 + const imports = []; + 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)) { + visited.add(normalized); + imports.push(normalized); + } + return ''; // 移除 import 语句 + }); + + // 移除 export 关键字 + content = content.replace(exportRegex, ''); + content = content.replace(exportBraceRegex, ''); + + // 处理依赖的模块 + for (const importPath of imports) { + const moduleContent = inlineFile(importPath); + const relativePath = path.relative(projectRoot, importPath); + modules.push(`\n// ============ ${relativePath} ============\n${moduleContent}\n`); + } + + return content; + } + + const mainContent = inlineFile(entryPath); + + // 将所有模块内容组合在一起,模块在前,主文件在后 + return modules.join('\n') + '\n// ============ Main ============\n' + mainContent; +} + function loadLogoDataUrl() { for (const candidate of logoCandidates) { @@ -142,8 +161,8 @@ function build() { let html = readFile(sourceFiles.html); const css = escapeForStyle(readFile(sourceFiles.css)); const i18n = escapeForScript(readFile(sourceFiles.i18n)); - const bundledApp = bundleApp(sourceFiles.app); - const app = escapeForScript(bundledApp); + const bundledApp = bundleApp(sourceFiles.app); + const app = escapeForScript(bundledApp); // 获取版本号并替换 const version = getVersion(); @@ -164,17 +183,17 @@ ${i18n} ` ); - const scriptTagRegex = /]*src="app\.js"[^>]*><\/script>/i; - if (scriptTagRegex.test(html)) { - html = html.replace( - scriptTagRegex, - `` - ); - } else { - console.warn('未找到 app.js 脚本标签,未内联应用代码。'); - } + 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/src/core/error-handler.js b/src/core/error-handler.js new file mode 100644 index 0000000..e4df15c --- /dev/null +++ b/src/core/error-handler.js @@ -0,0 +1,231 @@ +/** + * 错误处理器 + * 统一管理应用中的错误处理逻辑 + */ + +import { ERROR_MESSAGES } from '../utils/constants.js'; + +/** + * 错误处理器类 + * 提供统一的错误处理接口,确保错误处理的一致性 + */ +export class ErrorHandler { + /** + * 构造错误处理器 + * @param {Object} notificationService - 通知服务对象 + * @param {Function} notificationService.show - 显示通知的方法 + */ + constructor(notificationService) { + this.notificationService = notificationService; + } + + /** + * 处理更新操作失败 + * 包括显示错误通知和执行UI回滚操作 + * + * @param {Error} error - 错误对象 + * @param {string} context - 操作上下文(如"调试模式"、"代理设置") + * @param {Function} [rollbackFn] - UI回滚函数 + * + * @example + * try { + * await this.makeRequest('/debug', { method: 'PATCH', body: JSON.stringify({ enabled: true }) }); + * } catch (error) { + * this.errorHandler.handleUpdateError( + * error, + * '调试模式', + * () => document.getElementById('debug-toggle').checked = false + * ); + * } + */ + handleUpdateError(error, context, rollbackFn) { + console.error(`更新${context}失败:`, error); + const message = `更新${context}失败: ${error.message || ERROR_MESSAGES.OPERATION_FAILED}`; + this.notificationService.show(message, 'error'); + + // 执行回滚操作 + if (typeof rollbackFn === 'function') { + try { + rollbackFn(); + } catch (rollbackError) { + console.error('UI回滚操作失败:', rollbackError); + } + } + } + + /** + * 处理加载操作失败 + * + * @param {Error} error - 错误对象 + * @param {string} context - 加载内容的上下文(如"API密钥"、"使用统计") + * + * @example + * try { + * const data = await this.makeRequest('/api-keys'); + * this.renderApiKeys(data); + * } catch (error) { + * this.errorHandler.handleLoadError(error, 'API密钥'); + * } + */ + handleLoadError(error, context) { + console.error(`加载${context}失败:`, error); + const message = `加载${context}失败,请检查连接`; + this.notificationService.show(message, 'error'); + } + + /** + * 处理删除操作失败 + * + * @param {Error} error - 错误对象 + * @param {string} context - 删除内容的上下文 + */ + handleDeleteError(error, context) { + console.error(`删除${context}失败:`, error); + const message = `删除${context}失败: ${error.message || ERROR_MESSAGES.OPERATION_FAILED}`; + this.notificationService.show(message, 'error'); + } + + /** + * 处理添加操作失败 + * + * @param {Error} error - 错误对象 + * @param {string} context - 添加内容的上下文 + */ + handleAddError(error, context) { + console.error(`添加${context}失败:`, error); + const message = `添加${context}失败: ${error.message || ERROR_MESSAGES.OPERATION_FAILED}`; + this.notificationService.show(message, 'error'); + } + + /** + * 处理网络错误 + * 检测常见的网络问题并提供友好的错误提示 + * + * @param {Error} error - 错误对象 + */ + handleNetworkError(error) { + console.error('网络请求失败:', error); + + let message = ERROR_MESSAGES.NETWORK_ERROR; + + // 检测特定错误类型 + if (error.name === 'TypeError' && error.message.includes('fetch')) { + message = ERROR_MESSAGES.NETWORK_ERROR; + } else if (error.message && error.message.includes('timeout')) { + message = ERROR_MESSAGES.TIMEOUT; + } else if (error.message && error.message.includes('401')) { + message = ERROR_MESSAGES.UNAUTHORIZED; + } else if (error.message && error.message.includes('404')) { + message = ERROR_MESSAGES.NOT_FOUND; + } else if (error.message && error.message.includes('500')) { + message = ERROR_MESSAGES.SERVER_ERROR; + } else if (error.message) { + message = `网络错误: ${error.message}`; + } + + this.notificationService.show(message, 'error'); + } + + /** + * 处理验证错误 + * + * @param {string} fieldName - 字段名称 + * @param {string} [message] - 自定义错误消息 + */ + handleValidationError(fieldName, message) { + const errorMessage = message || `请输入有效的${fieldName}`; + this.notificationService.show(errorMessage, 'error'); + } + + /** + * 处理通用错误 + * 当错误类型不明确时使用 + * + * @param {Error} error - 错误对象 + * @param {string} [defaultMessage] - 默认错误消息 + */ + handleGenericError(error, defaultMessage) { + console.error('操作失败:', error); + const message = error.message || defaultMessage || ERROR_MESSAGES.OPERATION_FAILED; + this.notificationService.show(message, 'error'); + } + + /** + * 创建带错误处理的异步函数包装器 + * 自动捕获并处理错误 + * + * @param {Function} asyncFn - 异步函数 + * @param {string} context - 操作上下文 + * @param {Function} [rollbackFn] - 回滚函数 + * @returns {Function} 包装后的函数 + * + * @example + * const safeUpdate = this.errorHandler.withErrorHandling( + * () => this.makeRequest('/debug', { method: 'PATCH', body: '...' }), + * '调试模式', + * () => document.getElementById('debug-toggle').checked = false + * ); + * await safeUpdate(); + */ + withErrorHandling(asyncFn, context, rollbackFn) { + return async (...args) => { + try { + return await asyncFn(...args); + } catch (error) { + this.handleUpdateError(error, context, rollbackFn); + throw error; // 重新抛出以便调用者处理 + } + }; + } + + /** + * 创建带重试机制的错误处理包装器 + * + * @param {Function} asyncFn - 异步函数 + * @param {number} [maxRetries=3] - 最大重试次数 + * @param {number} [retryDelay=1000] - 重试延迟(毫秒) + * @returns {Function} 包装后的函数 + * + * @example + * const retryableFetch = this.errorHandler.withRetry( + * () => this.makeRequest('/config'), + * 3, + * 2000 + * ); + * const config = await retryableFetch(); + */ + withRetry(asyncFn, maxRetries = 3, retryDelay = 1000) { + return async (...args) => { + let lastError; + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + return await asyncFn(...args); + } catch (error) { + lastError = error; + console.warn(`尝试 ${attempt + 1}/${maxRetries} 失败:`, error); + + if (attempt < maxRetries - 1) { + // 等待后重试 + await new Promise(resolve => setTimeout(resolve, retryDelay)); + } + } + } + + // 所有尝试都失败 + throw lastError; + }; + } +} + +/** + * 创建错误处理器工厂函数 + * 便于在不同模块中创建错误处理器实例 + * + * @param {Function} showNotification - 显示通知的函数 + * @returns {ErrorHandler} 错误处理器实例 + */ +export function createErrorHandler(showNotification) { + return new ErrorHandler({ + show: showNotification + }); +} diff --git a/src/modules/ai-providers.js b/src/modules/ai-providers.js index 1dfb8d6..d188363 100644 --- a/src/modules/ai-providers.js +++ b/src/modules/ai-providers.js @@ -1,4 +1,4 @@ -// AI 提供商配置相关方法模块(当前只抽取 Gemini 相关逻辑) +// AI 提供商配置相关方法模块 // 这些函数依赖于 CLIProxyManager 实例上的 makeRequest/getConfig/clearCache/showNotification 等能力, // 以及 apiKeysModule 中的工具方法(如 applyHeadersToConfig/renderHeaderBadges)。 diff --git a/src/modules/api-keys.js b/src/modules/api-keys.js index 0e6bc6e..69ac4fc 100644 --- a/src/modules/api-keys.js +++ b/src/modules/api-keys.js @@ -49,46 +49,8 @@ export const apiKeysModule = { }).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 []; - }, + // 注意: escapeHtml, maskApiKey, normalizeArrayResponse + // 现在由 app.js 通过工具模块提供,通过 this 访问 // 添加一行自定义请求头输入 addHeaderField(wrapperId, header = {}) { diff --git a/src/utils/array.js b/src/utils/array.js new file mode 100644 index 0000000..80b8d8d --- /dev/null +++ b/src/utils/array.js @@ -0,0 +1,172 @@ +/** + * 数组工具函数模块 + * 提供数组处理、规范化、排序等功能 + */ + +/** + * 规范化 API 响应中的数组数据 + * 兼容多种服务端返回格式 + * + * @param {*} data - API 响应数据 + * @param {string} [key] - 数组字段的键名 + * @returns {Array} 规范化后的数组 + * + * @example + * // 直接返回数组 + * normalizeArrayResponse([1, 2, 3]) + * // 返回: [1, 2, 3] + * + * // 从对象中提取数组 + * normalizeArrayResponse({ 'api-keys': ['key1', 'key2'] }, 'api-keys') + * // 返回: ['key1', 'key2'] + * + * // 从 items 字段提取 + * normalizeArrayResponse({ items: ['a', 'b'] }) + * // 返回: ['a', 'b'] + */ +export function normalizeArrayResponse(data, key) { + // 如果本身就是数组,直接返回 + if (Array.isArray(data)) { + return data; + } + // 如果指定了 key,尝试从对象中提取 + if (key && data && Array.isArray(data[key])) { + return data[key]; + } + // 尝试从 items 字段提取(通用分页格式) + if (data && Array.isArray(data.items)) { + return data.items; + } + // 默认返回空数组 + return []; +} + +/** + * 数组去重 + * @param {Array} arr - 原数组 + * @param {Function} [keyFn] - 提取键的函数,用于对象数组去重 + * @returns {Array} 去重后的数组 + * + * @example + * uniqueArray([1, 2, 2, 3]) + * // 返回: [1, 2, 3] + * + * uniqueArray([{id: 1}, {id: 2}, {id: 1}], item => item.id) + * // 返回: [{id: 1}, {id: 2}] + */ +export function uniqueArray(arr, keyFn) { + if (!Array.isArray(arr)) return []; + + if (keyFn) { + const seen = new Set(); + return arr.filter(item => { + const key = keyFn(item); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + } + + return [...new Set(arr)]; +} + +/** + * 数组分组 + * @param {Array} arr - 原数组 + * @param {Function} keyFn - 提取分组键的函数 + * @returns {Object} 分组后的对象 + * + * @example + * groupBy([{type: 'a', val: 1}, {type: 'b', val: 2}, {type: 'a', val: 3}], item => item.type) + * // 返回: { a: [{type: 'a', val: 1}, {type: 'a', val: 3}], b: [{type: 'b', val: 2}] } + */ +export function groupBy(arr, keyFn) { + if (!Array.isArray(arr)) return {}; + + return arr.reduce((groups, item) => { + const key = keyFn(item); + if (!groups[key]) { + groups[key] = []; + } + groups[key].push(item); + return groups; + }, {}); +} + +/** + * 数组分块 + * @param {Array} arr - 原数组 + * @param {number} size - 每块大小 + * @returns {Array} 分块后的二维数组 + * + * @example + * chunk([1, 2, 3, 4, 5], 2) + * // 返回: [[1, 2], [3, 4], [5]] + */ +export function chunk(arr, size) { + if (!Array.isArray(arr) || size < 1) return []; + + const result = []; + for (let i = 0; i < arr.length; i += size) { + result.push(arr.slice(i, i + size)); + } + return result; +} + +/** + * 数组排序(不改变原数组) + * @param {Array} arr - 原数组 + * @param {Function} compareFn - 比较函数 + * @returns {Array} 排序后的新数组 + */ +export function sortArray(arr, compareFn) { + if (!Array.isArray(arr)) return []; + return [...arr].sort(compareFn); +} + +/** + * 按字段排序对象数组 + * @param {Array} arr - 对象数组 + * @param {string} key - 排序字段 + * @param {string} order - 排序顺序 'asc' 或 'desc' + * @returns {Array} 排序后的新数组 + * + * @example + * sortByKey([{age: 25}, {age: 20}, {age: 30}], 'age', 'asc') + * // 返回: [{age: 20}, {age: 25}, {age: 30}] + */ +export function sortByKey(arr, key, order = 'asc') { + if (!Array.isArray(arr)) return []; + + return [...arr].sort((a, b) => { + const aVal = a[key]; + const bVal = b[key]; + + if (aVal < bVal) return order === 'asc' ? -1 : 1; + if (aVal > bVal) return order === 'asc' ? 1 : -1; + return 0; + }); +} + +/** + * 安全地获取数组元素 + * @param {Array} arr - 数组 + * @param {number} index - 索引 + * @param {*} defaultValue - 默认值 + * @returns {*} 数组元素或默认值 + */ +export function safeGet(arr, index, defaultValue = undefined) { + if (!Array.isArray(arr) || index < 0 || index >= arr.length) { + return defaultValue; + } + return arr[index]; +} + +/** + * 检查数组是否为空 + * @param {*} arr - 待检查的值 + * @returns {boolean} 是否为空数组 + */ +export function isEmptyArray(arr) { + return !Array.isArray(arr) || arr.length === 0; +} diff --git a/src/utils/constants.js b/src/utils/constants.js new file mode 100644 index 0000000..28da0a9 --- /dev/null +++ b/src/utils/constants.js @@ -0,0 +1,274 @@ +/** + * 常量配置文件 + * 集中管理应用中的所有常量,避免魔法数字和硬编码字符串 + */ + +// ============================================================ +// 时间相关常量(毫秒) +// ============================================================ + +/** + * 配置缓存过期时间(30秒) + * 用于减少服务器压力,避免频繁请求配置数据 + */ +export const CACHE_EXPIRY_MS = 30 * 1000; + +/** + * 通知显示持续时间(3秒) + * 成功/错误/信息提示框的自动消失时间 + */ +export const NOTIFICATION_DURATION_MS = 3 * 1000; + +/** + * 状态更新定时器间隔(1秒) + * 连接状态和系统信息的更新频率 + */ +export const STATUS_UPDATE_INTERVAL_MS = 1 * 1000; + +/** + * 日志刷新延迟(500毫秒) + * 日志自动刷新的去抖延迟 + */ +export const LOG_REFRESH_DELAY_MS = 500; + +/** + * OAuth 状态轮询间隔(2秒) + * 检查 OAuth 认证完成状态的轮询频率 + */ +export const OAUTH_POLL_INTERVAL_MS = 2 * 1000; + +/** + * OAuth 最大轮询时间(5分钟) + * 超过此时间后停止轮询,认为授权超时 + */ +export const OAUTH_MAX_POLL_DURATION_MS = 5 * 60 * 1000; + +// ============================================================ +// 数据限制常量 +// ============================================================ + +/** + * 最大日志显示行数 + * 限制内存占用,避免大量日志导致页面卡顿 + */ +export const MAX_LOG_LINES = 10000; + +/** + * 认证文件列表默认每页显示数量 + */ +export const DEFAULT_AUTH_FILES_PAGE_SIZE = 9; + +/** + * 认证文件每页最小显示数量 + */ +export const MIN_AUTH_FILES_PAGE_SIZE = 3; + +/** + * 认证文件每页最大显示数量 + */ +export const MAX_AUTH_FILES_PAGE_SIZE = 60; + +/** + * 使用统计图表最大数据点数 + * 超过此数量将进行聚合,提高渲染性能 + */ +export const MAX_CHART_DATA_POINTS = 100; + +// ============================================================ +// 网络相关常量 +// ============================================================ + +/** + * 默认 API 服务器端口 + */ +export const DEFAULT_API_PORT = 8317; + +/** + * 默认 API 基础路径 + */ +export const DEFAULT_API_BASE = `http://localhost:${DEFAULT_API_PORT}`; + +/** + * 管理 API 路径前缀 + */ +export const MANAGEMENT_API_PREFIX = '/v0/management'; + +/** + * 请求超时时间(30秒) + */ +export const REQUEST_TIMEOUT_MS = 30 * 1000; + +// ============================================================ +// OAuth 相关常量 +// ============================================================ + +/** + * OAuth 卡片元素 ID 列表 + * 用于根据主机环境隐藏/显示不同的 OAuth 选项 + */ +export const OAUTH_CARD_IDS = [ + 'codex-oauth-card', + 'anthropic-oauth-card', + 'gemini-cli-oauth-card', + 'qwen-oauth-card', + 'iflow-oauth-card' +]; + +/** + * OAuth 提供商名称映射 + */ +export const OAUTH_PROVIDERS = { + CODEX: 'codex', + ANTHROPIC: 'anthropic', + GEMINI_CLI: 'gemini-cli', + QWEN: 'qwen', + IFLOW: 'iflow' +}; + +// ============================================================ +// 本地存储键名 +// ============================================================ + +/** + * 本地存储键名前缀 + */ +export const STORAGE_PREFIX = 'cliProxyApi_'; + +/** + * 存储 API 基础地址的键名 + */ +export const STORAGE_KEY_API_BASE = `${STORAGE_PREFIX}apiBase`; + +/** + * 存储管理密钥的键名 + */ +export const STORAGE_KEY_MANAGEMENT_KEY = `${STORAGE_PREFIX}managementKey`; + +/** + * 存储主题偏好的键名 + */ +export const STORAGE_KEY_THEME = `${STORAGE_PREFIX}theme`; + +/** + * 存储语言偏好的键名 + */ +export const STORAGE_KEY_LANGUAGE = `${STORAGE_PREFIX}language`; + +/** + * 存储认证文件页大小的键名 + */ +export const STORAGE_KEY_AUTH_FILES_PAGE_SIZE = `${STORAGE_PREFIX}authFilesPageSize`; + +// ============================================================ +// UI 相关常量 +// ============================================================ + +/** + * 主题选项 + */ +export const THEMES = { + LIGHT: 'light', + DARK: 'dark' +}; + +/** + * 支持的语言 + */ +export const LANGUAGES = { + ZH_CN: 'zh-CN', + EN_US: 'en-US' +}; + +/** + * 通知类型 + */ +export const NOTIFICATION_TYPES = { + SUCCESS: 'success', + ERROR: 'error', + INFO: 'info', + WARNING: 'warning' +}; + +/** + * 模态框尺寸 + */ +export const MODAL_SIZES = { + SMALL: 'small', + MEDIUM: 'medium', + LARGE: 'large' +}; + +// ============================================================ +// 正则表达式常量 +// ============================================================ + +/** + * URL 验证正则 + */ +export const URL_PATTERN = /^https?:\/\/.+/; + +/** + * Email 验证正则 + */ +export const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +/** + * 端口号验证正则(1-65535) + */ +export const PORT_PATTERN = /^([1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])$/; + +// ============================================================ +// 文件类型常量 +// ============================================================ + +/** + * 支持的认证文件类型 + */ +export const AUTH_FILE_TYPES = { + JSON: 'application/json', + YAML: 'application/x-yaml' +}; + +/** + * 认证文件最大大小(10MB) + */ +export const MAX_AUTH_FILE_SIZE = 10 * 1024 * 1024; + +// ============================================================ +// API 端点常量 +// ============================================================ + +/** + * 常用 API 端点路径 + */ +export const API_ENDPOINTS = { + CONFIG: '/config', + DEBUG: '/debug', + API_KEYS: '/api-keys', + PROVIDERS: '/providers', + AUTH_FILES: '/auth-files', + LOGS: '/logs', + USAGE_STATS: '/usage-stats', + CONNECTION: '/connection', + CODEX_API_KEY: '/codex-api-key', + ANTHROPIC_API_KEY: '/anthropic-api-key', + GEMINI_API_KEY: '/gemini-api-key', + OPENAI_API_KEY: '/openai-api-key' +}; + +// ============================================================ +// 错误消息常量 +// ============================================================ + +/** + * 通用错误消息 + */ +export const ERROR_MESSAGES = { + NETWORK_ERROR: '网络连接失败,请检查服务器状态', + TIMEOUT: '请求超时,请稍后重试', + UNAUTHORIZED: '未授权,请检查管理密钥', + NOT_FOUND: '资源不存在', + SERVER_ERROR: '服务器错误,请联系管理员', + INVALID_INPUT: '输入数据无效', + OPERATION_FAILED: '操作失败,请稍后重试' +}; diff --git a/src/utils/html.js b/src/utils/html.js new file mode 100644 index 0000000..13c08f1 --- /dev/null +++ b/src/utils/html.js @@ -0,0 +1,62 @@ +/** + * HTML 工具函数模块 + * 提供 HTML 字符串处理、XSS 防护等功能 + */ + +/** + * HTML 转义,防止 XSS 攻击 + * @param {*} value - 需要转义的值 + * @returns {string} 转义后的字符串 + * + * @example + * escapeHtml('') + * // 返回: '<script>alert("xss")</script>' + */ +export function escapeHtml(value) { + if (value === null || value === undefined) return ''; + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * HTML 反转义 + * @param {string} html - 需要反转义的 HTML 字符串 + * @returns {string} 反转义后的字符串 + */ +export function unescapeHtml(html) { + if (!html) return ''; + const textarea = document.createElement('textarea'); + textarea.innerHTML = html; + return textarea.value; +} + +/** + * 去除 HTML 标签,只保留文本内容 + * @param {string} html - HTML 字符串 + * @returns {string} 纯文本内容 + * + * @example + * stripHtmlTags('

Hello World

') + * // 返回: 'Hello World' + */ +export function stripHtmlTags(html) { + if (!html) return ''; + const div = document.createElement('div'); + div.innerHTML = html; + return div.textContent || div.innerText || ''; +} + +/** + * 安全地设置元素的 HTML 内容 + * @param {HTMLElement} element - 目标元素 + * @param {string} html - HTML 内容 + * @param {boolean} escape - 是否转义(默认 true) + */ +export function setSafeHtml(element, html, escape = true) { + if (!element) return; + element.innerHTML = escape ? escapeHtml(html) : html; +} diff --git a/src/utils/string.js b/src/utils/string.js new file mode 100644 index 0000000..a0f5e4f --- /dev/null +++ b/src/utils/string.js @@ -0,0 +1,116 @@ +/** + * 字符串工具函数模块 + * 提供字符串处理、格式化、掩码等功能 + */ + +/** + * 遮蔽 API 密钥显示,保护敏感信息 + * @param {*} key - API 密钥 + * @returns {string} 遮蔽后的密钥字符串 + * + * @example + * maskApiKey('sk-1234567890abcdef') + * // 返回: 'sk-1...cdef' + */ +export function 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; +} + +/** + * 截断字符串到指定长度,超出部分用省略号代替 + * @param {string} str - 原字符串 + * @param {number} maxLength - 最大长度 + * @param {string} suffix - 后缀(默认 '...') + * @returns {string} 截断后的字符串 + * + * @example + * truncateString('This is a very long string', 10) + * // 返回: 'This is...' + */ +export function truncateString(str, maxLength, suffix = '...') { + if (!str || str.length <= maxLength) return str || ''; + return str.substring(0, maxLength - suffix.length) + suffix; +} + +/** + * 格式化文件大小 + * @param {number} bytes - 字节数 + * @param {number} decimals - 小数位数(默认 2) + * @returns {string} 格式化后的大小字符串 + * + * @example + * formatFileSize(1536) + * // 返回: '1.50 KB' + */ +export function formatFileSize(bytes, decimals = 2) { + if (bytes === 0) return '0 Bytes'; + if (!bytes || isNaN(bytes)) return ''; + + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; +} + +/** + * 首字母大写 + * @param {string} str - 原字符串 + * @returns {string} 首字母大写后的字符串 + */ +export function capitalize(str) { + if (!str) return ''; + return str.charAt(0).toUpperCase() + str.slice(1); +} + +/** + * 生成随机字符串 + * @param {number} length - 字符串长度 + * @param {string} charset - 字符集(默认字母数字) + * @returns {string} 随机字符串 + */ +export function randomString(length = 8, charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789') { + let result = ''; + for (let i = 0; i < length; i++) { + result += charset.charAt(Math.floor(Math.random() * charset.length)); + } + return result; +} + +/** + * 检查字符串是否为空或仅包含空白字符 + * @param {string} str - 待检查的字符串 + * @returns {boolean} 是否为空 + */ +export function isBlank(str) { + return !str || /^\s*$/.test(str); +} + +/** + * 将字符串转换为 kebab-case + * @param {string} str - 原字符串 + * @returns {string} kebab-case 字符串 + * + * @example + * toKebabCase('helloWorld') + * // 返回: 'hello-world' + */ +export function toKebabCase(str) { + if (!str) return ''; + return str + .replace(/([a-z])([A-Z])/g, '$1-$2') + .replace(/[\s_]+/g, '-') + .toLowerCase(); +}