mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-18 18:50:49 +08:00
feat: initialize new React application structure with TypeScript, ESLint, and Prettier configurations, while removing legacy files and adding new components and pages for enhanced functionality
This commit is contained in:
@@ -1,172 +0,0 @@
|
||||
/**
|
||||
* 数组工具函数模块
|
||||
* 提供数组处理、规范化、排序等功能
|
||||
*/
|
||||
|
||||
/**
|
||||
* 规范化 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;
|
||||
}
|
||||
34
src/utils/connection.ts
Normal file
34
src/utils/connection.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { DEFAULT_API_PORT, MANAGEMENT_API_PREFIX } from './constants';
|
||||
|
||||
export const normalizeApiBase = (input: string): string => {
|
||||
let base = (input || '').trim();
|
||||
if (!base) return '';
|
||||
base = base.replace(/\/?v0\/management\/?$/i, '');
|
||||
base = base.replace(/\/+$/i, '');
|
||||
if (!/^https?:\/\//i.test(base)) {
|
||||
base = `http://${base}`;
|
||||
}
|
||||
return base;
|
||||
};
|
||||
|
||||
export const computeApiUrl = (base: string): string => {
|
||||
const normalized = normalizeApiBase(base);
|
||||
if (!normalized) return '';
|
||||
return `${normalized}${MANAGEMENT_API_PREFIX}`;
|
||||
};
|
||||
|
||||
export const detectApiBaseFromLocation = (): string => {
|
||||
try {
|
||||
const { protocol, hostname, port } = window.location;
|
||||
const normalizedPort = port ? `:${port}` : '';
|
||||
return normalizeApiBase(`${protocol}//${hostname}${normalizedPort}`);
|
||||
} catch (error) {
|
||||
console.warn('Failed to detect api base from location, fallback to default', error);
|
||||
return normalizeApiBase(`http://localhost:${DEFAULT_API_PORT}`);
|
||||
}
|
||||
};
|
||||
|
||||
export const isLocalhost = (hostname: string): boolean => {
|
||||
const value = (hostname || '').toLowerCase();
|
||||
return value === 'localhost' || value === '127.0.0.1' || value === '[::1]';
|
||||
};
|
||||
@@ -1,282 +0,0 @@
|
||||
/**
|
||||
* 常量配置文件
|
||||
* 集中管理应用中的所有常量,避免魔法数字和硬编码字符串
|
||||
*/
|
||||
|
||||
// ============================================================
|
||||
// 时间相关常量(毫秒)
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 配置缓存过期时间(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 = 2000;
|
||||
|
||||
/**
|
||||
* 日志接口获取数量上限
|
||||
* 限制后端返回的日志行数,避免一次拉取过多数据
|
||||
*/
|
||||
export const LOG_FETCH_LIMIT = 2500;
|
||||
|
||||
/**
|
||||
* 认证文件列表默认每页显示数量
|
||||
*/
|
||||
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',
|
||||
'antigravity-oauth-card',
|
||||
'gemini-cli-oauth-card',
|
||||
'qwen-oauth-card',
|
||||
'iflow-oauth-card'
|
||||
];
|
||||
|
||||
/**
|
||||
* OAuth 提供商名称映射
|
||||
*/
|
||||
export const OAUTH_PROVIDERS = {
|
||||
CODEX: 'codex',
|
||||
ANTHROPIC: 'anthropic',
|
||||
ANTIGRAVITY: 'antigravity',
|
||||
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: '操作失败,请稍后重试'
|
||||
};
|
||||
66
src/utils/constants.ts
Normal file
66
src/utils/constants.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* 常量定义
|
||||
* 从原项目 src/utils/constants.js 迁移
|
||||
*/
|
||||
|
||||
// 缓存过期时间(毫秒)
|
||||
export const CACHE_EXPIRY_MS = 30 * 1000; // 与基线保持一致,减少管理端压力
|
||||
|
||||
// 网络与版本信息
|
||||
export const DEFAULT_API_PORT = 8317;
|
||||
export const MANAGEMENT_API_PREFIX = '/v0/management';
|
||||
export const REQUEST_TIMEOUT_MS = 30 * 1000;
|
||||
export const VERSION_HEADER_KEYS = ['x-cpa-version', 'x-server-version'];
|
||||
export const BUILD_DATE_HEADER_KEYS = ['x-cpa-build-date', 'x-server-build-date'];
|
||||
export const STATUS_UPDATE_INTERVAL_MS = 1000;
|
||||
export const LOG_REFRESH_DELAY_MS = 500;
|
||||
|
||||
// 日志相关
|
||||
export const MAX_LOG_LINES = 2000;
|
||||
export const LOG_FETCH_LIMIT = 2500;
|
||||
|
||||
// 认证文件分页
|
||||
export const DEFAULT_AUTH_FILES_PAGE_SIZE = 20;
|
||||
export const MIN_AUTH_FILES_PAGE_SIZE = 10;
|
||||
export const MAX_AUTH_FILES_PAGE_SIZE = 100;
|
||||
export const MAX_AUTH_FILE_SIZE = 10 * 1024 * 1024;
|
||||
|
||||
// 本地存储键名
|
||||
export const STORAGE_KEY_AUTH = 'cli-proxy-auth';
|
||||
export const STORAGE_KEY_THEME = 'cli-proxy-theme';
|
||||
export const STORAGE_KEY_LANGUAGE = 'cli-proxy-language';
|
||||
export const STORAGE_KEY_SIDEBAR = 'cli-proxy-sidebar-collapsed';
|
||||
export const STORAGE_KEY_AUTH_FILES_PAGE_SIZE = 'cli-proxy-auth-files-page-size';
|
||||
|
||||
// 通知持续时间
|
||||
export const NOTIFICATION_DURATION_MS = 3000;
|
||||
|
||||
// OAuth 卡片 ID 列表
|
||||
export const OAUTH_CARD_IDS = [
|
||||
'codex-oauth-card',
|
||||
'anthropic-oauth-card',
|
||||
'antigravity-oauth-card',
|
||||
'gemini-cli-oauth-card',
|
||||
'qwen-oauth-card',
|
||||
'iflow-oauth-card'
|
||||
];
|
||||
export const OAUTH_PROVIDERS = {
|
||||
CODEX: 'codex',
|
||||
ANTHROPIC: 'anthropic',
|
||||
ANTIGRAVITY: 'antigravity',
|
||||
GEMINI_CLI: 'gemini-cli',
|
||||
QWEN: 'qwen',
|
||||
IFLOW: 'iflow'
|
||||
} as const;
|
||||
|
||||
// API 端点
|
||||
export const API_ENDPOINTS = {
|
||||
CONFIG: '/config',
|
||||
LOGIN: '/login',
|
||||
API_KEYS: '/api-keys',
|
||||
PROVIDERS: '/providers',
|
||||
AUTH_FILES: '/auth-files',
|
||||
OAUTH: '/oauth',
|
||||
USAGE: '/usage',
|
||||
LOGS: '/logs'
|
||||
} as const;
|
||||
279
src/utils/dom.js
279
src/utils/dom.js
@@ -1,279 +0,0 @@
|
||||
/**
|
||||
* DOM 操作工具函数模块
|
||||
* 提供高性能的 DOM 操作方法
|
||||
*/
|
||||
|
||||
/**
|
||||
* 批量渲染列表项,使用 DocumentFragment 减少重绘
|
||||
* @param {HTMLElement} container - 容器元素
|
||||
* @param {Array} items - 数据项数组
|
||||
* @param {Function} renderItemFn - 渲染单个项目的函数,返回 HTML 字符串或 Element
|
||||
* @param {boolean} append - 是否追加模式(默认 false,清空后渲染)
|
||||
*
|
||||
* @example
|
||||
* renderList(container, files, (file) => `
|
||||
* <div class="file-item">${file.name}</div>
|
||||
* `);
|
||||
*/
|
||||
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<HTMLElement>} 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
101
src/utils/encryption.ts
Normal file
101
src/utils/encryption.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* 加密工具函数
|
||||
* 从原项目 src/utils/secure-storage.js 迁移
|
||||
*/
|
||||
|
||||
const ENC_PREFIX = 'enc::v1::';
|
||||
const SECRET_SALT = 'cli-proxy-api-webui::secure-storage';
|
||||
|
||||
let cachedKeyBytes: Uint8Array | null = null;
|
||||
|
||||
function encodeText(text: string): Uint8Array {
|
||||
const encoder = new TextEncoder();
|
||||
return encoder.encode(text);
|
||||
}
|
||||
|
||||
function decodeText(bytes: Uint8Array): string {
|
||||
const decoder = new TextDecoder();
|
||||
return decoder.decode(bytes);
|
||||
}
|
||||
|
||||
function getKeyBytes(): Uint8Array {
|
||||
if (cachedKeyBytes) return cachedKeyBytes;
|
||||
|
||||
try {
|
||||
const host = window.location.host;
|
||||
const ua = navigator.userAgent;
|
||||
cachedKeyBytes = encodeText(`${SECRET_SALT}|${host}|${ua}`);
|
||||
} catch (error) {
|
||||
console.warn('Encryption fallback to simple key:', error);
|
||||
cachedKeyBytes = encodeText(SECRET_SALT);
|
||||
}
|
||||
|
||||
return cachedKeyBytes;
|
||||
}
|
||||
|
||||
function xorBytes(data: Uint8Array, keyBytes: Uint8Array): Uint8Array {
|
||||
const result = new Uint8Array(data.length);
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
result[i] = data[i] ^ keyBytes[i % keyBytes.length];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function toBase64(bytes: Uint8Array): string {
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function fromBase64(base64: string): Uint8Array {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加密数据
|
||||
*/
|
||||
export function encryptData(value: string): string {
|
||||
if (!value) return value;
|
||||
|
||||
try {
|
||||
const keyBytes = getKeyBytes();
|
||||
const encrypted = xorBytes(encodeText(value), keyBytes);
|
||||
return `${ENC_PREFIX}${toBase64(encrypted)}`;
|
||||
} catch (error) {
|
||||
console.warn('Encryption failed, fallback to plaintext:', error);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密数据
|
||||
*/
|
||||
export function decryptData(payload: string): string {
|
||||
if (!payload || !payload.startsWith(ENC_PREFIX)) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
try {
|
||||
const encodedBody = payload.slice(ENC_PREFIX.length);
|
||||
const encrypted = fromBase64(encodedBody);
|
||||
const decrypted = xorBytes(encrypted, getKeyBytes());
|
||||
return decodeText(decrypted);
|
||||
} catch (error) {
|
||||
console.warn('Decryption failed, return as-is:', error);
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已加密
|
||||
*/
|
||||
export function isEncrypted(value: string): boolean {
|
||||
return value?.startsWith(ENC_PREFIX) || false;
|
||||
}
|
||||
70
src/utils/format.ts
Normal file
70
src/utils/format.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* 格式化工具函数
|
||||
* 从原项目 src/utils/string.js 迁移
|
||||
*/
|
||||
|
||||
/**
|
||||
* 隐藏 API Key 中间部分
|
||||
*/
|
||||
export function maskApiKey(key: string, visibleChars: number = 4): string {
|
||||
if (!key || key.length <= visibleChars * 2) {
|
||||
return key;
|
||||
}
|
||||
|
||||
const start = key.slice(0, visibleChars);
|
||||
const end = key.slice(-visibleChars);
|
||||
const maskedLength = Math.min(key.length - visibleChars * 2, 20);
|
||||
const masked = '*'.repeat(maskedLength);
|
||||
|
||||
return `${start}${masked}${end}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化文件大小
|
||||
*/
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
const k = 1024;
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(2)} ${units[i]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期时间
|
||||
*/
|
||||
export function formatDateTime(date: string | Date): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
|
||||
if (isNaN(d.getTime())) {
|
||||
return 'Invalid Date';
|
||||
}
|
||||
|
||||
return d.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化数字(添加千位分隔符)
|
||||
*/
|
||||
export function formatNumber(num: number): string {
|
||||
return num.toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
/**
|
||||
* 截断长文本
|
||||
*/
|
||||
export function truncateText(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) {
|
||||
return text;
|
||||
}
|
||||
return text.slice(0, maxLength) + '...';
|
||||
}
|
||||
39
src/utils/headers.ts
Normal file
39
src/utils/headers.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* 自定义请求头处理工具
|
||||
*/
|
||||
|
||||
export interface HeaderEntry {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export function buildHeaderObject(input?: HeaderEntry[] | Record<string, string | undefined | null>): Record<string, string> {
|
||||
if (!input) return {};
|
||||
|
||||
if (Array.isArray(input)) {
|
||||
return input.reduce<Record<string, string>>((acc, item) => {
|
||||
const key = item?.key?.trim();
|
||||
const value = item?.value?.trim();
|
||||
if (key && value !== undefined && value !== null && value !== '') {
|
||||
acc[key] = value;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
return Object.entries(input).reduce<Record<string, string>>((acc, [rawKey, rawValue]) => {
|
||||
const key = rawKey?.trim();
|
||||
const value = typeof rawValue === 'string' ? rawValue.trim() : rawValue;
|
||||
if (key && value !== undefined && value !== null && value !== '') {
|
||||
acc[key] = String(value);
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
export function headersToEntries(headers?: Record<string, string | undefined | null>): HeaderEntry[] {
|
||||
if (!headers || typeof headers !== 'object') return [];
|
||||
return Object.entries(headers)
|
||||
.filter(([, value]) => value !== undefined && value !== null && value !== '')
|
||||
.map(([key, value]) => ({ key, value: String(value) }));
|
||||
}
|
||||
87
src/utils/helpers.ts
Normal file
87
src/utils/helpers.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* 辅助工具函数
|
||||
* 从原项目 src/utils/array.js, dom.js, html.js 迁移
|
||||
*/
|
||||
|
||||
/**
|
||||
* 规范化数组响应(处理后端可能返回非数组的情况)
|
||||
*/
|
||||
export function normalizeArrayResponse<T>(data: T | T[] | null | undefined): T[] {
|
||||
if (!data) return [];
|
||||
if (Array.isArray(data)) return data;
|
||||
return [data];
|
||||
}
|
||||
|
||||
/**
|
||||
* 防抖函数
|
||||
*/
|
||||
export function debounce<T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
delay: number
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timeoutId: ReturnType<typeof setTimeout>;
|
||||
|
||||
return function (this: any, ...args: Parameters<T>) {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => func.apply(this, args), delay);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 节流函数
|
||||
*/
|
||||
export function throttle<T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
limit: number
|
||||
): (...args: Parameters<T>) => void {
|
||||
let inThrottle: boolean;
|
||||
|
||||
return function (this: any, ...args: Parameters<T>) {
|
||||
if (!inThrottle) {
|
||||
func.apply(this, args);
|
||||
inThrottle = true;
|
||||
setTimeout(() => (inThrottle = false), limit);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML 转义(防 XSS)
|
||||
*/
|
||||
export function escapeHtml(text: string): string {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成唯一 ID
|
||||
*/
|
||||
export function generateId(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 深拷贝对象
|
||||
*/
|
||||
export function deepClone<T>(obj: T): T {
|
||||
if (obj === null || typeof obj !== 'object') return obj;
|
||||
|
||||
if (obj instanceof Date) return new Date(obj.getTime()) as any;
|
||||
if (obj instanceof Array) return obj.map((item) => deepClone(item)) as any;
|
||||
|
||||
const clonedObj = {} as T;
|
||||
for (const key in obj) {
|
||||
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
||||
clonedObj[key] = deepClone((obj as any)[key]);
|
||||
}
|
||||
}
|
||||
return clonedObj;
|
||||
}
|
||||
|
||||
/**
|
||||
* 延迟函数
|
||||
*/
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
/**
|
||||
* HTML 工具函数模块
|
||||
* 提供 HTML 字符串处理、XSS 防护等功能
|
||||
*/
|
||||
|
||||
/**
|
||||
* HTML 转义,防止 XSS 攻击
|
||||
* @param {*} value - 需要转义的值
|
||||
* @returns {string} 转义后的字符串
|
||||
*
|
||||
* @example
|
||||
* escapeHtml('<script>alert("xss")</script>')
|
||||
* // 返回: '<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, '"')
|
||||
.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('<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;
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
/**
|
||||
* 模型工具函数
|
||||
* 提供模型列表的规范化与去重能力
|
||||
*/
|
||||
export function normalizeModelList(payload, { dedupe = false } = {}) {
|
||||
const toModel = (entry) => {
|
||||
if (typeof entry === 'string') {
|
||||
return { name: entry };
|
||||
}
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
return null;
|
||||
}
|
||||
const name = entry.id || entry.name || entry.model || entry.value;
|
||||
if (!name) return null;
|
||||
|
||||
const alias = entry.alias || entry.display_name || entry.displayName;
|
||||
const description = entry.description || entry.note || entry.comment;
|
||||
const model = { name: String(name) };
|
||||
if (alias && alias !== name) {
|
||||
model.alias = String(alias);
|
||||
}
|
||||
if (description) {
|
||||
model.description = String(description);
|
||||
}
|
||||
return model;
|
||||
};
|
||||
|
||||
let models = [];
|
||||
|
||||
if (Array.isArray(payload)) {
|
||||
models = payload.map(toModel).filter(Boolean);
|
||||
} else if (payload && typeof payload === 'object') {
|
||||
if (Array.isArray(payload.data)) {
|
||||
models = payload.data.map(toModel).filter(Boolean);
|
||||
} else if (Array.isArray(payload.models)) {
|
||||
models = payload.models.map(toModel).filter(Boolean);
|
||||
}
|
||||
}
|
||||
|
||||
if (!dedupe) {
|
||||
return models;
|
||||
}
|
||||
|
||||
const seen = new Set();
|
||||
return models.filter(model => {
|
||||
const key = (model?.name || '').toLowerCase();
|
||||
if (!key || seen.has(key)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
const MODEL_CATEGORIES = [
|
||||
{ id: 'gpt', label: 'GPT', patterns: [/gpt/i, /\bo\d\b/i, /\bo\d+\.?/i, /\bchatgpt/i] },
|
||||
{ id: 'claude', label: 'Claude', patterns: [/claude/i] },
|
||||
{ id: 'gemini', label: 'Gemini', patterns: [/gemini/i, /\bgai\b/i] },
|
||||
{ id: 'kimi', label: 'Kimi', patterns: [/kimi/i] },
|
||||
{ id: 'qwen', label: 'Qwen', patterns: [/qwen/i] },
|
||||
{ id: 'glm', label: 'GLM', patterns: [/glm/i, /chatglm/i] },
|
||||
{ id: 'grok', label: 'Grok', patterns: [/grok/i] },
|
||||
{ id: 'deepseek', label: 'DeepSeek', patterns: [/deepseek/i] }
|
||||
];
|
||||
|
||||
function matchCategory(text) {
|
||||
for (const category of MODEL_CATEGORIES) {
|
||||
if (category.patterns.some(pattern => pattern.test(text))) {
|
||||
return category.id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function classifyModels(models = [], { otherLabel = 'Other' } = {}) {
|
||||
const groups = MODEL_CATEGORIES.map(category => ({
|
||||
id: category.id,
|
||||
label: category.label,
|
||||
items: []
|
||||
}));
|
||||
|
||||
const otherGroup = { id: 'other', label: otherLabel, items: [] };
|
||||
|
||||
models.forEach(model => {
|
||||
const name = (model?.name || '').toString();
|
||||
const alias = (model?.alias || '').toString();
|
||||
const haystack = `${name} ${alias}`.toLowerCase();
|
||||
const matchedId = matchCategory(haystack);
|
||||
const target = matchedId ? groups.find(group => group.id === matchedId) : null;
|
||||
|
||||
if (target) {
|
||||
target.items.push(model);
|
||||
} else {
|
||||
otherGroup.items.push(model);
|
||||
}
|
||||
});
|
||||
|
||||
const populatedGroups = groups.filter(group => group.items.length > 0);
|
||||
if (otherGroup.items.length) {
|
||||
populatedGroups.push(otherGroup);
|
||||
}
|
||||
|
||||
return populatedGroups;
|
||||
}
|
||||
118
src/utils/models.ts
Normal file
118
src/utils/models.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* 模型工具函数
|
||||
* 迁移自基线 utils/models.js
|
||||
*/
|
||||
|
||||
export interface ModelInfo {
|
||||
name: string;
|
||||
alias?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const MODEL_CATEGORIES = [
|
||||
{ id: 'gpt', label: 'GPT', patterns: [/gpt/i, /\bo\d\b/i, /\bo\d+\.?/i, /\bchatgpt/i] },
|
||||
{ id: 'claude', label: 'Claude', patterns: [/claude/i] },
|
||||
{ id: 'gemini', label: 'Gemini', patterns: [/gemini/i, /\bgai\b/i] },
|
||||
{ id: 'kimi', label: 'Kimi', patterns: [/kimi/i] },
|
||||
{ id: 'qwen', label: 'Qwen', patterns: [/qwen/i] },
|
||||
{ id: 'glm', label: 'GLM', patterns: [/glm/i, /chatglm/i] },
|
||||
{ id: 'grok', label: 'Grok', patterns: [/grok/i] },
|
||||
{ id: 'deepseek', label: 'DeepSeek', patterns: [/deepseek/i] }
|
||||
];
|
||||
|
||||
const matchCategory = (text: string) => {
|
||||
for (const category of MODEL_CATEGORIES) {
|
||||
if (category.patterns.some((pattern) => pattern.test(text))) {
|
||||
return category.id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export function normalizeModelList(payload: any, { dedupe = false } = {}): ModelInfo[] {
|
||||
const toModel = (entry: any): ModelInfo | null => {
|
||||
if (typeof entry === 'string') {
|
||||
return { name: entry };
|
||||
}
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
return null;
|
||||
}
|
||||
const name = entry.id || entry.name || entry.model || entry.value;
|
||||
if (!name) return null;
|
||||
|
||||
const alias = entry.alias || entry.display_name || entry.displayName;
|
||||
const description = entry.description || entry.note || entry.comment;
|
||||
const model: ModelInfo = { name: String(name) };
|
||||
if (alias && alias !== name) {
|
||||
model.alias = String(alias);
|
||||
}
|
||||
if (description) {
|
||||
model.description = String(description);
|
||||
}
|
||||
return model;
|
||||
};
|
||||
|
||||
let models: (ModelInfo | null)[] = [];
|
||||
|
||||
if (Array.isArray(payload)) {
|
||||
models = payload.map(toModel);
|
||||
} else if (payload && typeof payload === 'object') {
|
||||
if (Array.isArray(payload.data)) {
|
||||
models = payload.data.map(toModel);
|
||||
} else if (Array.isArray(payload.models)) {
|
||||
models = payload.models.map(toModel);
|
||||
}
|
||||
}
|
||||
|
||||
const normalized = models.filter(Boolean) as ModelInfo[];
|
||||
if (!dedupe) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
return normalized.filter((model) => {
|
||||
const key = (model?.name || '').toLowerCase();
|
||||
if (!key || seen.has(key)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export interface ModelGroup {
|
||||
id: string;
|
||||
label: string;
|
||||
items: ModelInfo[];
|
||||
}
|
||||
|
||||
export function classifyModels(models: ModelInfo[] = [], { otherLabel = 'Other' } = {}): ModelGroup[] {
|
||||
const groups: ModelGroup[] = MODEL_CATEGORIES.map((category) => ({
|
||||
id: category.id,
|
||||
label: category.label,
|
||||
items: []
|
||||
}));
|
||||
|
||||
const otherGroup: ModelGroup = { id: 'other', label: otherLabel, items: [] };
|
||||
|
||||
models.forEach((model) => {
|
||||
const name = (model?.name || '').toString();
|
||||
const alias = (model?.alias || '').toString();
|
||||
const haystack = `${name} ${alias}`.toLowerCase();
|
||||
const matchedId = matchCategory(haystack);
|
||||
const target = matchedId ? groups.find((group) => group.id === matchedId) : null;
|
||||
|
||||
if (target) {
|
||||
target.items.push(model);
|
||||
} else {
|
||||
otherGroup.items.push(model);
|
||||
}
|
||||
});
|
||||
|
||||
const populatedGroups = groups.filter((group) => group.items.length > 0);
|
||||
if (otherGroup.items.length) {
|
||||
populatedGroups.push(otherGroup);
|
||||
}
|
||||
|
||||
return populatedGroups;
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
// 简单的浏览器端加密存储封装
|
||||
// 仅用于避免本地缓存中明文暴露敏感值,无法替代服务端安全控制
|
||||
|
||||
const ENC_PREFIX = 'enc::v1::';
|
||||
const SECRET_SALT = 'cli-proxy-api-webui::secure-storage';
|
||||
|
||||
const encoder = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null;
|
||||
const decoder = typeof TextDecoder !== 'undefined' ? new TextDecoder() : null;
|
||||
|
||||
let cachedKeyBytes = null;
|
||||
|
||||
function encodeText(text) {
|
||||
if (encoder) return encoder.encode(text);
|
||||
const result = new Uint8Array(text.length);
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
result[i] = text.charCodeAt(i) & 0xff;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function decodeText(bytes) {
|
||||
if (decoder) return decoder.decode(bytes);
|
||||
let result = '';
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
result += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function getKeyBytes() {
|
||||
if (cachedKeyBytes) return cachedKeyBytes;
|
||||
try {
|
||||
const host = typeof window !== 'undefined' ? window.location.host : 'unknown-host';
|
||||
const ua = typeof navigator !== 'undefined' ? navigator.userAgent : 'unknown-ua';
|
||||
cachedKeyBytes = encodeText(`${SECRET_SALT}|${host}|${ua}`);
|
||||
} catch (error) {
|
||||
console.warn('Secure storage fallback to plain text:', error);
|
||||
cachedKeyBytes = encodeText(SECRET_SALT);
|
||||
}
|
||||
return cachedKeyBytes;
|
||||
}
|
||||
|
||||
function xorBytes(data, keyBytes) {
|
||||
const result = new Uint8Array(data.length);
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
result[i] = data[i] ^ keyBytes[i % keyBytes.length];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function toBase64(bytes) {
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function fromBase64(base64) {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function encode(value) {
|
||||
if (value === null || value === undefined) return value;
|
||||
try {
|
||||
const keyBytes = getKeyBytes();
|
||||
const encrypted = xorBytes(encodeText(String(value)), keyBytes);
|
||||
return `${ENC_PREFIX}${toBase64(encrypted)}`;
|
||||
} catch (error) {
|
||||
console.warn('Secure storage encode fallback:', error);
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function decode(payload) {
|
||||
if (payload === null || payload === undefined) return payload;
|
||||
if (!payload.startsWith(ENC_PREFIX)) {
|
||||
return payload;
|
||||
}
|
||||
try {
|
||||
const encodedBody = payload.slice(ENC_PREFIX.length);
|
||||
const encrypted = fromBase64(encodedBody);
|
||||
const decrypted = xorBytes(encrypted, getKeyBytes());
|
||||
return decodeText(decrypted);
|
||||
} catch (error) {
|
||||
console.warn('Secure storage decode fallback:', error);
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
|
||||
export const secureStorage = {
|
||||
setItem(key, value, { encrypt = true } = {}) {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
if (value === null || value === undefined) {
|
||||
localStorage.removeItem(key);
|
||||
return;
|
||||
}
|
||||
const storedValue = encrypt ? encode(value) : String(value);
|
||||
localStorage.setItem(key, storedValue);
|
||||
},
|
||||
|
||||
getItem(key, { decrypt = true } = {}) {
|
||||
if (typeof localStorage === 'undefined') return null;
|
||||
const raw = localStorage.getItem(key);
|
||||
if (raw === null) return null;
|
||||
return decrypt ? decode(raw) : raw;
|
||||
},
|
||||
|
||||
removeItem(key) {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
localStorage.removeItem(key);
|
||||
},
|
||||
|
||||
migratePlaintextKeys(keys = []) {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
keys.forEach((key) => {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (raw && !String(raw).startsWith(ENC_PREFIX)) {
|
||||
this.setItem(key, raw, { encrypt: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,116 +0,0 @@
|
||||
/**
|
||||
* 字符串工具函数模块
|
||||
* 提供字符串处理、格式化、掩码等功能
|
||||
*/
|
||||
|
||||
/**
|
||||
* 遮蔽 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();
|
||||
}
|
||||
128
src/utils/usage.ts
Normal file
128
src/utils/usage.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* 使用统计相关工具
|
||||
* 迁移自基线 modules/usage.js 的纯逻辑部分
|
||||
*/
|
||||
|
||||
import { maskApiKey } from './format';
|
||||
|
||||
export interface KeyStatBucket {
|
||||
success: number;
|
||||
failure: number;
|
||||
}
|
||||
|
||||
export interface KeyStats {
|
||||
bySource: Record<string, KeyStatBucket>;
|
||||
byAuthIndex: Record<string, KeyStatBucket>;
|
||||
}
|
||||
|
||||
const normalizeAuthIndex = (value: any) => {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value.toString();
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 对使用数据中的敏感字段进行遮罩
|
||||
*/
|
||||
export function maskUsageSensitiveValue(value: unknown, masker: (val: string) => string = maskApiKey): string {
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
const raw = typeof value === 'string' ? value : String(value);
|
||||
if (!raw) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let masked = raw;
|
||||
|
||||
const queryRegex = /([?&])(api[-_]?key|key|token|access_token|authorization)=([^&#\s]+)/gi;
|
||||
masked = masked.replace(queryRegex, (_full, prefix, keyName, valuePart) => `${prefix}${keyName}=${masker(valuePart)}`);
|
||||
|
||||
const headerRegex = /(api[-_]?key|key|token|access[-_]?token|authorization)\s*([:=])\s*([A-Za-z0-9._-]+)/gi;
|
||||
masked = masked.replace(headerRegex, (_full, keyName, separator, valuePart) => `${keyName}${separator}${masker(valuePart)}`);
|
||||
|
||||
const keyLikeRegex = /(sk-[A-Za-z0-9]{6,}|AI[a-zA-Z0-9_-]{6,}|AIza[0-9A-Za-z-_]{8,}|hf_[A-Za-z0-9]{6,}|pk_[A-Za-z0-9]{6,}|rk_[A-Za-z0-9]{6,})/g;
|
||||
masked = masked.replace(keyLikeRegex, (match) => masker(match));
|
||||
|
||||
if (masked === raw) {
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed && !/\s/.test(trimmed)) {
|
||||
const looksLikeKey =
|
||||
/^sk-/i.test(trimmed) ||
|
||||
/^AI/i.test(trimmed) ||
|
||||
/^AIza/i.test(trimmed) ||
|
||||
/^hf_/i.test(trimmed) ||
|
||||
/^pk_/i.test(trimmed) ||
|
||||
/^rk_/i.test(trimmed) ||
|
||||
(!/[\\/]/.test(trimmed) && (/\d/.test(trimmed) || trimmed.length >= 10)) ||
|
||||
trimmed.length >= 24;
|
||||
if (looksLikeKey) {
|
||||
return masker(trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return masked;
|
||||
}
|
||||
|
||||
/**
|
||||
* 依据 usage 数据计算密钥使用统计
|
||||
*/
|
||||
export function computeKeyStats(usageData: any, masker: (val: string) => string = maskApiKey): KeyStats {
|
||||
if (!usageData) {
|
||||
return { bySource: {}, byAuthIndex: {} };
|
||||
}
|
||||
|
||||
const sourceStats: Record<string, KeyStatBucket> = {};
|
||||
const authIndexStats: Record<string, KeyStatBucket> = {};
|
||||
|
||||
const ensureBucket = (bucket: Record<string, KeyStatBucket>, key: string) => {
|
||||
if (!bucket[key]) {
|
||||
bucket[key] = { success: 0, failure: 0 };
|
||||
}
|
||||
return bucket[key];
|
||||
};
|
||||
|
||||
const apis = usageData.apis || {};
|
||||
Object.values(apis as any).forEach((apiEntry: any) => {
|
||||
const models = apiEntry?.models || {};
|
||||
|
||||
Object.values(models as any).forEach((modelEntry: any) => {
|
||||
const details = modelEntry?.details || [];
|
||||
|
||||
details.forEach((detail: any) => {
|
||||
const source = maskUsageSensitiveValue(detail?.source, masker);
|
||||
const authIndexKey = normalizeAuthIndex(detail?.auth_index);
|
||||
const isFailed = detail?.failed === true;
|
||||
|
||||
if (source) {
|
||||
const bucket = ensureBucket(sourceStats, source);
|
||||
if (isFailed) {
|
||||
bucket.failure += 1;
|
||||
} else {
|
||||
bucket.success += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (authIndexKey) {
|
||||
const bucket = ensureBucket(authIndexStats, authIndexKey);
|
||||
if (isFailed) {
|
||||
bucket.failure += 1;
|
||||
} else {
|
||||
bucket.success += 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
bySource: sourceStats,
|
||||
byAuthIndex: authIndexStats
|
||||
};
|
||||
}
|
||||
56
src/utils/validation.ts
Normal file
56
src/utils/validation.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 验证工具函数
|
||||
*/
|
||||
|
||||
/**
|
||||
* 验证 URL 格式
|
||||
*/
|
||||
export function isValidUrl(url: string): boolean {
|
||||
try {
|
||||
new URL(url);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 API Base URL
|
||||
*/
|
||||
export function isValidApiBase(apiBase: string): boolean {
|
||||
if (!apiBase) return false;
|
||||
|
||||
// 允许 http/https 协议
|
||||
const urlPattern = /^https?:\/\/.+/i;
|
||||
return urlPattern.test(apiBase);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 API Key 格式
|
||||
*/
|
||||
export function isValidApiKey(key: string): boolean {
|
||||
if (!key || key.length < 8) return false;
|
||||
|
||||
// 基础验证:不包含空格
|
||||
return !/\s/.test(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 JSON 格式
|
||||
*/
|
||||
export function isValidJson(str: string): boolean {
|
||||
try {
|
||||
JSON.parse(str);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Email 格式
|
||||
*/
|
||||
export function isValidEmail(email: string): boolean {
|
||||
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailPattern.test(email);
|
||||
}
|
||||
Reference in New Issue
Block a user