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:
Supra4E8C
2025-12-07 11:32:31 +08:00
parent 8e4132200d
commit 450964fb1a
144 changed files with 14223 additions and 21647 deletions

View File

@@ -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
View 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]';
};

View File

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

View File

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

View File

@@ -1,62 +0,0 @@
/**
* 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;
}

View File

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

View File

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

View File

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