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 = /`
- );
- } else {
- console.warn('未找到 app.js 脚本标签,未内联应用代码。');
- }
+ const 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();
+}