refactor(app): centralize UI constants and error handling

This commit is contained in:
hkfires
2025-11-17 12:06:36 +08:00
parent 04b6d0a9c4
commit f82bcef990
9 changed files with 970 additions and 123 deletions

172
src/utils/array.js Normal file
View File

@@ -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<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;
}

274
src/utils/constants.js Normal file
View File

@@ -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: '操作失败,请稍后重试'
};

62
src/utils/html.js Normal file
View File

@@ -0,0 +1,62 @@
/**
* HTML 工具函数模块
* 提供 HTML 字符串处理、XSS 防护等功能
*/
/**
* HTML 转义,防止 XSS 攻击
* @param {*} value - 需要转义的值
* @returns {string} 转义后的字符串
*
* @example
* escapeHtml('<script>alert("xss")</script>')
* // 返回: '&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;'
*/
export function escapeHtml(value) {
if (value === null || value === undefined) return '';
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* 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('<p>Hello <strong>World</strong></p>')
* // 返回: '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;
}

116
src/utils/string.js Normal file
View File

@@ -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();
}