Compare commits

...

44 Commits

Author SHA1 Message Date
hkfires
93eb7f4717 refactor(auth): simplify JSON details modal layout 2025-11-20 19:33:06 +08:00
Supra4E8C
6e0dec4567 feat(versioning): implement UI and server version tracking with build date display in footer 2025-11-20 18:35:22 +08:00
Supra4E8C
23d8d20dbf refactor(auth-files): simplify modal structure and improve JSON display styling 2025-11-20 18:03:10 +08:00
Supra4E8C
c5010adb82 refactor(stats): enhance key statistics handling by introducing source and auth index categorization 2025-11-20 14:08:10 +08:00
Supra4E8C
8f4320c837 feat(styles): add new file type badges for 'gemini-cli' and 'aistudio' with dark theme support 2025-11-20 12:12:02 +08:00
Supra4E8C
7267fc36ca fix(ai-providers): update key display format to include API key label 2025-11-19 12:30:53 +08:00
Supra4E8C
897f3f5910 feat(security): implement secure storage for sensitive data and migrate existing keys 2025-11-19 12:25:45 +08:00
hkfires
ae0e92a6ae refactor(api-keys): fetch keys from API and reduce log limit 2025-11-17 15:32:33 +08:00
hkfires
fea36b1ca9 refactor: centralize usage stats and refine api key cache 2025-11-17 14:55:57 +08:00
hkfires
ad520b7b26 refactor(app): reuse debounce util and connection module 2025-11-17 14:45:42 +08:00
hkfires
f7682435ed feat(auth-files): add JSON upload handling for auth files 2025-11-17 13:22:43 +08:00
hkfires
fe5d997398 feat(config): add section-based caching and tunable status interval 2025-11-17 12:56:53 +08:00
hkfires
f82bcef990 refactor(app): centralize UI constants and error handling 2025-11-17 12:06:36 +08:00
hkfires
04b6d0a9c4 feat(usage-stats): support configurable chart lines 2025-11-16 22:01:03 +08:00
hkfires
bf40caacc3 fix(auth-files): improve pagination info and filters 2025-11-16 22:01:03 +08:00
hkfires
bbd0a56052 refactor(app): modularize UI and usage logic 2025-11-16 22:01:03 +08:00
Supra4E8C
6308074c11 feat(app.js, i18n, index.html): refactor model filtering to chart line selections
- Replaced the model filter dropdown with multiple chart line selectors for improved data visualization.
- Updated event handling to manage changes in chart line selections and refresh chart data accordingly.
- Enhanced internationalization strings for chart line labels in both English and Chinese.
- Adjusted the HTML structure to accommodate the new chart line selection UI.
2025-11-16 17:27:58 +08:00
Supra4E8C
aa852025a5 feat(app.js, i18n, index.html, styles.css): implement model filtering in usage statistics
- Added a model filter dropdown to the usage statistics UI, allowing users to filter data by model.
- Implemented methods to handle model filter changes and update chart data accordingly.
- Enhanced internationalization strings for model filter labels in both English and Chinese.
- Updated styles for the model filter to improve layout and user experience.
2025-11-16 11:25:18 +08:00
Supra4E8C
6928cfed28 feat(app.js): add usage detail collection and hourly series generation
- Implemented methods to collect usage details from API requests and build recent hourly series for requests and tokens.
- Added functionality to format hour labels and extract total tokens from usage details.
- Enhanced the existing chart initialization logic to utilize the new hourly series data for improved visualization.
2025-11-16 11:08:47 +08:00
Supra4E8C
8f71b0d811 style(styles.css): improve layout and spacing for input groups and buttons
- Added margin-bottom to input groups for better spacing.
- Introduced margin-top and alignment for buttons in various input lists to enhance layout consistency.
2025-11-15 14:59:22 +08:00
Supra4E8C
edb723c12b feat(app.js, i18n, index.html, styles.css): enhance auth file management with search and pagination controls
- Implemented search functionality for auth files, allowing users to filter by name, type, or provider.
- Added pagination controls to manage the display of auth files, improving navigation through large datasets.
- Updated internationalization strings to support new search and pagination features in both English and Chinese.
- Enhanced styles for the auth file toolbar, search input, and pagination controls for better user experience.
2025-11-15 14:54:10 +08:00
Supra4E8C
295befe42b feat(app.js): enhance file name handling in CLIProxyManager
- Added logic to generate candidate names based on the raw file name, type, and provider prefixes.
- Implemented checks to return statistics for candidates and their masked versions, improving the accuracy of file management.
- Enhanced the overall handling of file names to support better matching and retrieval of associated stats.
2025-11-14 18:24:22 +08:00
Supra4E8C
a07faddeff feat(app.js, i18n, index.html, styles.css): implement pagination for auth file management
- Added pagination controls to the auth file list in the UI, allowing users to navigate through large sets of files.
- Enhanced the rendering logic to support pagination, including updating the current page and total pages dynamically.
- Updated internationalization strings for pagination in both English and Chinese.
- Introduced new styles for pagination controls to improve user experience and accessibility.
2025-11-14 18:10:37 +08:00
Supra4E8C
5be40092f7 feat(app.js, i18n): add custom model management for Claude API
- Introduced UI elements for adding and managing custom models in the CLIProxyManager, including model name and alias inputs.
- Enhanced the display of model counts and badges in the provider item view.
- Updated internationalization strings to support new model management features in both English and Chinese.
2025-11-13 17:52:13 +08:00
Luis Pater
d422606f99 feat(app.js, styles.css): add main flag for Gemini CLI auth files, update styles and filtering logic 2025-11-13 09:22:14 +08:00
Supra4E8C
8b07159c35 refactor(app.js): improve API key handling and display logic
- Enhanced the masking and display of API keys to ensure proper handling of null or undefined values.
- Updated the HTML rendering logic to use normalized and escaped key values for better security and consistency.
- Improved the handling of key arguments in button actions to prevent potential issues with special characters.
- Refactored the file name handling in the UI to ensure proper escaping and display of file names.
2025-11-12 12:04:58 +08:00
Supra4E8C
5b1be05eb9 feat(index.html): add Vertex AI credential import UI
- Implemented the UI for importing Vertex AI credentials, including file selection and input fields for location and JSON key.
- Enhanced internationalization support with appropriate labels and hints in both English and Chinese.
- Removed the previous implementation of the Vertex AI credential import section to streamline the code.
2025-11-11 18:28:56 +08:00
Supra4E8C
a4fd672458 feat(app.js, i18n, index.html, styles): implement Vertex AI credential import feature
- Added functionality for importing Vertex AI credentials, including file selection and upload handling in the CLIProxyManager.
- Updated UI components in index.html to support the new Vertex AI credential import feature.
- Enhanced internationalization strings to provide appropriate labels and messages for the Vertex AI import functionality in both English and Chinese.
- Introduced new styles for the Vertex AI credential import section to ensure a consistent user experience.
2025-11-11 18:19:35 +08:00
Supra4E8C
6f1c7b168d feat(app.js, i18n, index.html): add request logging and WebSocket authentication settings
- Introduced toggles for enabling request logging and WebSocket authentication in the UI.
- Implemented methods to load and update the respective settings in the CLIProxyManager.
- Updated internationalization strings to support new features in both English and Chinese.
2025-11-11 12:32:56 +08:00
Luis Pater
1d7408cb25 feat(app.js, i18n): enhance auth file management with filtering and update related UI messages 2025-11-11 11:50:12 +08:00
Supra4E8C
3468fd8373 feat(app.js, styles): enhance connection state handling and update editor dimensions
- Added a new property to track the last connection state in CLIProxyManager.
- Updated the config editor availability logic to reflect changes in connection state.
- Increased minimum height for various editor components in styles to improve usability.
- Introduced responsive styles for smaller screens to ensure proper display of editor elements.
2025-11-10 18:07:31 +08:00
Supra4E8C
4f15c3f5c5 feat(app.js, i18n): enforce required base URL for Codex API configuration
- Updated the Codex API configuration modals to mark the Base URL field as required.
- Added validation to ensure the Base URL is provided before submission, with appropriate error notifications.
- Updated internationalization strings to reflect the change in the Base URL label from "Optional" to "Required" in both English and Chinese.
2025-11-10 12:16:46 +08:00
hkfires
72cd117aab fix(logs): exclude all /v0/management/ API entries from viewer 2025-11-10 10:40:28 +08:00
hkfires
5d62cd91f2 perf(cli): prefetch /usage, derive keyStats, reuse across renders 2025-11-10 10:32:57 +08:00
Supra4E8C
6837100dec feat(app.js, i18n, styles): implement custom headers functionality in API key management
- Added the ability to input custom HTTP headers for API keys in the CLIProxyManager.
- Introduced new methods for adding, populating, collecting, and applying custom headers.
- Updated the UI to include input fields for custom headers in the modals for adding and editing API keys.
- Enhanced internationalization strings to support custom headers in both English and Chinese.
- Added corresponding styles for the new header input fields and badges in the UI.
2025-11-09 18:27:29 +08:00
Supra4E8C
8542041981 feat(app.js, i18n): support batch addition of Gemini API keys with enhanced UI
- Updated the Gemini API key modal to allow input of multiple keys, each on a new line.
- Added a hint for users on how to input multiple keys and updated the corresponding internationalization strings.
- Enhanced the key addition logic to handle success, skipped, and failed counts, providing detailed feedback to users.
- Improved error handling and notifications for batch operations.
2025-11-09 18:06:56 +08:00
Supra4E8C
35ceab0dae feat(app.js, i18n): enhance Gemini API key management with base URL support
- Updated the CLIProxyManager to handle both API keys and optional base URLs for Gemini.
- Added new input fields in the modal for base URL entry during key addition and editing.
- Updated internationalization strings to include labels and placeholders for the base URL in both English and Chinese.
- Improved key normalization and rendering logic to accommodate the new structure.
2025-11-09 17:51:38 +08:00
Supra4E8C
d3fe186df7 refactor(app.js): remove modal click event listener to streamline modal behavior 2025-11-09 17:20:02 +08:00
Supra4E8C
5aff22a20b fix(ui): update modal styles for better responsiveness and overflow handling 2025-11-09 17:17:49 +08:00
hkfires
aa1dedc932 feat(ui): mark runtime-only auth files and disable actions 2025-11-07 22:02:29 +08:00
hkfires
61e75eee97 feat(auth-files): add AI Studio and Gemini CLI with filename masking 2025-11-07 21:46:46 +08:00
hkfires
3a2d96725f fix(ui): align file-item footer and adjust badge spacing 2025-11-06 14:23:07 +08:00
Supra4E8C
8283e99909 fix(app.js):尝试修复codex配置异常丢失数据的问题 2025-11-05 18:06:22 +08:00
hkfires
181cba6886 feat(logs): limit lines, incremental updates, and highlighting 2025-11-03 18:44:50 +08:00
27 changed files with 8505 additions and 4490 deletions

1
.gitignore vendored
View File

@@ -24,4 +24,5 @@ Thumbs.db
CLAUDE.md
.claude
AGENTS.md
.codex
.serena

4608
app.js

File diff suppressed because it is too large Load Diff

View File

@@ -85,6 +85,57 @@ function ensureDistDir() {
fs.mkdirSync(distDir);
}
// 匹配各种 import 语句
const importRegex = /import\s+(?:{[^}]*}|[\w*\s,{}]+)\s+from\s+['"]([^'"]+)['"];?/gm;
// 匹配 export 关键字(包括 export const, export function, export class, export async function 等)
const exportRegex = /^export\s+(?=const|let|var|function|class|default|async)/gm;
// 匹配单独的 export {} 或 export { ... } from '...'
const exportBraceRegex = /^export\s*{[^}]*}\s*(?:from\s+['"][^'"]+['"];?)?$/gm;
function bundleApp(entryPath) {
const visited = new Set();
const modules = [];
function inlineFile(filePath) {
let content = readFile(filePath);
const dir = path.dirname(filePath);
// 收集所有 import 语句
const imports = [];
content = content.replace(importRegex, (match, specifier) => {
const targetPath = path.resolve(dir, specifier);
const normalized = path.normalize(targetPath);
if (!fs.existsSync(normalized)) {
throw new Error(`无法解析模块: ${specifier} (from ${filePath})`);
}
if (!visited.has(normalized)) {
visited.add(normalized);
imports.push(normalized);
}
return ''; // 移除 import 语句
});
// 移除 export 关键字
content = content.replace(exportRegex, '');
content = content.replace(exportBraceRegex, '');
// 处理依赖的模块
for (const importPath of imports) {
const moduleContent = inlineFile(importPath);
const relativePath = path.relative(projectRoot, importPath);
modules.push(`\n// ============ ${relativePath} ============\n${moduleContent}\n`);
}
return content;
}
const mainContent = inlineFile(entryPath);
// 将所有模块内容组合在一起,模块在前,主文件在后
return modules.join('\n') + '\n// ============ Main ============\n' + mainContent;
}
function loadLogoDataUrl() {
for (const candidate of logoCandidates) {
const filePath = path.join(projectRoot, candidate);
@@ -110,7 +161,8 @@ function build() {
let html = readFile(sourceFiles.html);
const css = escapeForStyle(readFile(sourceFiles.css));
const i18n = escapeForScript(readFile(sourceFiles.i18n));
const app = escapeForScript(readFile(sourceFiles.app));
const bundledApp = bundleApp(sourceFiles.app);
const app = escapeForScript(bundledApp);
// 获取版本号并替换
const version = getVersion();
@@ -131,12 +183,17 @@ ${i18n}
</script>`
);
const scriptTagRegex = /<script[^>]*src="app\.js"[^>]*><\/script>/i;
if (scriptTagRegex.test(html)) {
html = html.replace(
'<script src="app.js"></script>',
scriptTagRegex,
`<script>
${app}
</script>`
);
} else {
console.warn('未找到 app.js 脚本标签,未内联应用代码。');
}
const logoDataUrl = loadLogoDataUrl();
if (logoDataUrl) {

180
i18n.js
View File

@@ -41,6 +41,13 @@ const i18n = {
'common.failure': '失败',
'common.unknown_error': '未知错误',
'common.copy': '复制',
'common.custom_headers_label': '自定义请求头',
'common.custom_headers_hint': '可选,设置需要附带到请求中的 HTTP 头,名称和值均不能为空。',
'common.custom_headers_add': '添加请求头',
'common.custom_headers_key_placeholder': 'Header 名称,例如 X-Custom-Header',
'common.custom_headers_value_placeholder': 'Header 值',
'common.model_name_placeholder': '模型名称,例如 claude-3-5-sonnet-20241022',
'common.model_alias_placeholder': '模型别名 (可选)',
// 页面标题
'title.main': 'CLI Proxy API Management Center',
@@ -108,6 +115,9 @@ const i18n = {
'basic_settings.usage_statistics_enable': '启用使用统计',
'basic_settings.logging_title': '日志记录',
'basic_settings.logging_to_file_enable': '启用日志记录到文件',
'basic_settings.request_log_enable': '启用请求日志',
'basic_settings.ws_auth_title': 'WebSocket 鉴权',
'basic_settings.ws_auth_enable': '启用 /ws/* 鉴权',
// API 密钥管理
'api_keys.title': 'API 密钥管理',
@@ -131,8 +141,11 @@ const i18n = {
'ai_providers.gemini_empty_desc': '点击上方按钮添加第一个密钥',
'ai_providers.gemini_item_title': 'Gemini密钥',
'ai_providers.gemini_add_modal_title': '添加Gemini API密钥',
'ai_providers.gemini_add_modal_key_label': 'API密钥:',
'ai_providers.gemini_add_modal_key_placeholder': '输入Gemini API密钥',
'ai_providers.gemini_add_modal_key_label': 'API密钥',
'ai_providers.gemini_add_modal_key_placeholder': '输入 Gemini API 密钥',
'ai_providers.gemini_add_modal_key_hint': '逐条输入密钥,可同时指定可选 Base URL。',
'ai_providers.gemini_keys_add_btn': '添加密钥',
'ai_providers.gemini_base_url_placeholder': '可选 Base URL如 https://generativelanguage.googleapis.com',
'ai_providers.gemini_edit_modal_title': '编辑Gemini API密钥',
'ai_providers.gemini_edit_modal_key_label': 'API密钥:',
'ai_providers.gemini_delete_confirm': '确定要删除这个Gemini密钥吗',
@@ -145,13 +158,13 @@ const i18n = {
'ai_providers.codex_add_modal_title': '添加Codex API配置',
'ai_providers.codex_add_modal_key_label': 'API密钥:',
'ai_providers.codex_add_modal_key_placeholder': '请输入Codex API密钥',
'ai_providers.codex_add_modal_url_label': 'Base URL (可选):',
'ai_providers.codex_add_modal_url_label': 'Base URL (必填):',
'ai_providers.codex_add_modal_url_placeholder': '例如: https://api.example.com',
'ai_providers.codex_add_modal_proxy_label': '代理 URL (可选):',
'ai_providers.codex_add_modal_proxy_placeholder': '例如: socks5://proxy.example.com:1080',
'ai_providers.codex_edit_modal_title': '编辑Codex API配置',
'ai_providers.codex_edit_modal_key_label': 'API密钥:',
'ai_providers.codex_edit_modal_url_label': 'Base URL (可选):',
'ai_providers.codex_edit_modal_url_label': 'Base URL (必填):',
'ai_providers.codex_edit_modal_proxy_label': '代理 URL (可选):',
'ai_providers.codex_delete_confirm': '确定要删除这个Codex配置吗',
@@ -172,6 +185,10 @@ const i18n = {
'ai_providers.claude_edit_modal_url_label': 'Base URL (可选):',
'ai_providers.claude_edit_modal_proxy_label': '代理 URL (可选):',
'ai_providers.claude_delete_confirm': '确定要删除这个Claude配置吗',
'ai_providers.claude_models_label': '自定义模型 (可选):',
'ai_providers.claude_models_hint': '为空表示使用全部模型;可填写 name[, alias] 以限制或重命名模型。',
'ai_providers.claude_models_add_btn': '添加模型',
'ai_providers.claude_models_count': '模型数量',
'ai_providers.openai_title': 'OpenAI 兼容提供商',
'ai_providers.openai_add_button': '添加提供商',
@@ -182,10 +199,12 @@ const i18n = {
'ai_providers.openai_add_modal_name_placeholder': '例如: openrouter',
'ai_providers.openai_add_modal_url_label': 'Base URL:',
'ai_providers.openai_add_modal_url_placeholder': '例如: https://openrouter.ai/api/v1',
'ai_providers.openai_add_modal_keys_label': 'API密钥 (每行一个):',
'ai_providers.openai_add_modal_keys_placeholder': 'sk-key1\nsk-key2',
'ai_providers.openai_add_modal_keys_proxy_label': '代理 URL (按行对应,可选):',
'ai_providers.openai_add_modal_keys_proxy_placeholder': 'socks5://proxy.example.com:1080\n',
'ai_providers.openai_add_modal_keys_label': 'API密钥',
'ai_providers.openai_edit_modal_keys_label': 'API密钥',
'ai_providers.openai_keys_hint': '每个密钥可搭配一个可选代理地址,更便于管理。',
'ai_providers.openai_keys_add_btn': '添加密钥',
'ai_providers.openai_key_placeholder': '输入 sk- 开头的密钥',
'ai_providers.openai_proxy_placeholder': '可选代理 URL (如 socks5://...)',
'ai_providers.openai_add_modal_models_label': '模型列表 (name[, alias] 每行一个):',
'ai_providers.openai_models_hint': '示例gpt-4o-mini 或 moonshotai/kimi-k2:free, kimi-k2',
'ai_providers.openai_model_name_placeholder': '模型名称,如 moonshotai/kimi-k2:free',
@@ -194,8 +213,7 @@ const i18n = {
'ai_providers.openai_edit_modal_title': '编辑OpenAI兼容提供商',
'ai_providers.openai_edit_modal_name_label': '提供商名称:',
'ai_providers.openai_edit_modal_url_label': 'Base URL:',
'ai_providers.openai_edit_modal_keys_label': 'API密钥 (每行一个):',
'ai_providers.openai_edit_modal_keys_proxy_label': '代理 URL (按行对应,可选):',
'ai_providers.openai_edit_modal_keys_label': 'API密钥',
'ai_providers.openai_edit_modal_models_label': '模型列表 (name[, alias] 每行一个):',
'ai_providers.openai_delete_confirm': '确定要删除这个OpenAI提供商吗',
'ai_providers.openai_keys_count': '密钥数量',
@@ -205,38 +223,74 @@ const i18n = {
// 认证文件管理
'auth_files.title': '认证文件管理',
'auth_files.title_section': '认证文件',
'auth_files.description': '这里管理 QwenGemini 的认证配置文件。上传 JSON 格式的认证文件以启用相应的 AI 服务。',
'auth_files.description': '这里集中管理 CLI Proxy 支持的所有 JSON 认证文件(如 QwenGemini、Vertex 等),上传后即可在运行时启用相应的 AI 服务。',
'auth_files.upload_button': '上传文件',
'auth_files.delete_all_button': '删除全部',
'auth_files.empty_title': '暂无认证文件',
'auth_files.empty_desc': '点击上方按钮上传第一个文件',
'auth_files.search_empty_title': '没有匹配的配置文件',
'auth_files.search_empty_desc': '请调整筛选条件或清空搜索关键字再试一次。',
'auth_files.file_size': '大小',
'auth_files.file_modified': '修改时间',
'auth_files.download_button': '下载',
'auth_files.delete_button': '删除',
'auth_files.delete_confirm': '确定要删除文件',
'auth_files.delete_all_confirm': '确定要删除所有认证文件吗?此操作不可恢复!',
'auth_files.delete_filtered_confirm': '确定要删除筛选出的 {type} 认证文件吗?此操作不可恢复!',
'auth_files.upload_error_json': '只能上传JSON文件',
'auth_files.upload_success': '文件上传成功',
'auth_files.download_success': '文件下载成功',
'auth_files.delete_success': '文件删除成功',
'auth_files.delete_all_success': '成功删除',
'auth_files.delete_filtered_success': '成功删除 {count} 个 {type} 认证文件',
'auth_files.delete_filtered_partial': '{type} 认证文件删除完成,成功 {success} 个,失败 {failed} 个',
'auth_files.delete_filtered_none': '当前筛选类型 ({type}) 下没有可删除的认证文件',
'auth_files.files_count': '个文件',
'auth_files.pagination_prev': '上一页',
'auth_files.pagination_next': '下一页',
'auth_files.pagination_info': '第 {current} / {total} 页 · 共 {count} 个文件',
'auth_files.search_label': '搜索配置文件',
'auth_files.search_placeholder': '输入名称、类型或提供方关键字',
'auth_files.page_size_label': '单页数量',
'auth_files.page_size_unit': '个/页',
'auth_files.filter_all': '全部',
'auth_files.filter_qwen': 'Qwen',
'auth_files.filter_gemini': 'Gemini',
'auth_files.filter_gemini-cli': 'GeminiCLI',
'auth_files.filter_aistudio': 'AIStudio',
'auth_files.filter_claude': 'Claude',
'auth_files.filter_codex': 'Codex',
'auth_files.filter_iflow': 'iFlow',
'auth_files.filter_vertex': 'Vertex',
'auth_files.filter_empty': '空文件',
'auth_files.filter_unknown': '其他',
'auth_files.type_qwen': 'Qwen',
'auth_files.type_gemini': 'Gemini',
'auth_files.type_gemini-cli': 'GeminiCLI',
'auth_files.type_aistudio': 'AIStudio',
'auth_files.type_claude': 'Claude',
'auth_files.type_codex': 'Codex',
'auth_files.type_iflow': 'iFlow',
'auth_files.type_vertex': 'Vertex',
'auth_files.type_empty': '空文件',
'auth_files.type_unknown': '其他',
'vertex_import.title': 'Vertex AI 凭证导入',
'vertex_import.description': '上传 Google 服务账号 JSON使用 CLI vertex-import 同步规则写入 auth-dir/vertex-<project>.json。',
'vertex_import.location_label': '目标区域 (可选)',
'vertex_import.location_placeholder': 'us-central1',
'vertex_import.location_hint': '留空表示使用默认区域 us-central1。',
'vertex_import.file_label': '服务账号密钥 JSON',
'vertex_import.file_hint': '仅支持 Google Cloud service account key JSON 文件,私钥会自动规范化。',
'vertex_import.file_placeholder': '尚未选择文件',
'vertex_import.choose_file': '选择文件',
'vertex_import.import_button': '导入 Vertex 凭证',
'vertex_import.file_required': '请先选择 .json 凭证文件',
'vertex_import.success': 'Vertex 凭证导入成功',
'vertex_import.result_title': '凭证已保存',
'vertex_import.result_project': '项目 ID',
'vertex_import.result_email': '服务账号',
'vertex_import.result_location': '区域',
'vertex_import.result_file': '存储文件',
// Codex OAuth
@@ -320,6 +374,10 @@ const i18n = {
'usage_stats.by_hour': '按小时',
'usage_stats.by_day': '按天',
'usage_stats.refresh': '刷新',
'usage_stats.chart_line_label_1': '曲线 1',
'usage_stats.chart_line_label_2': '曲线 2',
'usage_stats.chart_line_label_3': '曲线 3',
'usage_stats.chart_line_hidden': '不显示',
'usage_stats.no_data': '暂无数据',
'usage_stats.loading_error': '加载失败',
'usage_stats.api_endpoint': 'API端点',
@@ -390,15 +448,21 @@ const i18n = {
'notification.quota_switch_preview_updated': '预览模型切换设置已更新',
'notification.usage_statistics_updated': '使用统计设置已更新',
'notification.logging_to_file_updated': '日志记录设置已更新',
'notification.request_log_updated': '请求日志设置已更新',
'notification.ws_auth_updated': 'WebSocket 鉴权设置已更新',
'notification.api_key_added': 'API密钥添加成功',
'notification.api_key_updated': 'API密钥更新成功',
'notification.api_key_deleted': 'API密钥删除成功',
'notification.gemini_key_added': 'Gemini密钥添加成功',
'notification.gemini_key_updated': 'Gemini密钥更新成功',
'notification.gemini_key_deleted': 'Gemini密钥删除成功',
'notification.gemini_multi_input_required': '请先输入至少一个Gemini密钥',
'notification.gemini_multi_failed': 'Gemini密钥批量添加失败',
'notification.gemini_multi_summary': 'Gemini批量添加完成成功 {success},跳过 {skipped},失败 {failed}',
'notification.codex_config_added': 'Codex配置添加成功',
'notification.codex_config_updated': 'Codex配置更新成功',
'notification.codex_config_deleted': 'Codex配置删除成功',
'notification.codex_base_url_required': '请填写Codex Base URL',
'notification.claude_config_added': 'Claude配置添加成功',
'notification.claude_config_updated': 'Claude配置更新成功',
'notification.claude_config_deleted': 'Claude配置删除成功',
@@ -444,7 +508,9 @@ const i18n = {
'sidebar.toggle_collapse': '收起侧边栏',
// 页脚
'footer.version': '版本',
'footer.api_version': 'CLI Proxy API 版本',
'footer.build_date': '构建时间',
'footer.version': '管理中心版本',
'footer.author': '作者'
},
@@ -483,6 +549,13 @@ const i18n = {
'common.failure': 'Failure',
'common.unknown_error': 'Unknown error',
'common.copy': 'Copy',
'common.custom_headers_label': 'Custom Headers',
'common.custom_headers_hint': 'Optional HTTP headers to send with the request. Leave blank to remove.',
'common.custom_headers_add': 'Add Header',
'common.custom_headers_key_placeholder': 'Header name, e.g. X-Custom-Header',
'common.custom_headers_value_placeholder': 'Header value',
'common.model_name_placeholder': 'Model name, e.g. claude-3-5-sonnet-20241022',
'common.model_alias_placeholder': 'Model alias (optional)',
// Page titles
'title.main': 'CLI Proxy API Management Center',
@@ -550,6 +623,9 @@ const i18n = {
'basic_settings.usage_statistics_enable': 'Enable usage statistics',
'basic_settings.logging_title': 'Logging',
'basic_settings.logging_to_file_enable': 'Enable logging to file',
'basic_settings.request_log_enable': 'Enable request logging',
'basic_settings.ws_auth_title': 'WebSocket Authentication',
'basic_settings.ws_auth_enable': 'Require auth for /ws/*',
// API Keys management
'api_keys.title': 'API Keys Management',
@@ -573,8 +649,11 @@ const i18n = {
'ai_providers.gemini_empty_desc': 'Click the button above to add the first key',
'ai_providers.gemini_item_title': 'Gemini Key',
'ai_providers.gemini_add_modal_title': 'Add Gemini API Key',
'ai_providers.gemini_add_modal_key_label': 'API Key:',
'ai_providers.gemini_add_modal_key_placeholder': 'Please enter Gemini API key',
'ai_providers.gemini_add_modal_key_label': 'API Keys:',
'ai_providers.gemini_add_modal_key_placeholder': 'Enter Gemini API key',
'ai_providers.gemini_add_modal_key_hint': 'Add keys one by one and optionally specify a Base URL.',
'ai_providers.gemini_keys_add_btn': 'Add Key',
'ai_providers.gemini_base_url_placeholder': 'Optional Base URL, e.g. https://generativelanguage.googleapis.com',
'ai_providers.gemini_edit_modal_title': 'Edit Gemini API Key',
'ai_providers.gemini_edit_modal_key_label': 'API Key:',
'ai_providers.gemini_delete_confirm': 'Are you sure you want to delete this Gemini key?',
@@ -587,13 +666,13 @@ const i18n = {
'ai_providers.codex_add_modal_title': 'Add Codex API Configuration',
'ai_providers.codex_add_modal_key_label': 'API Key:',
'ai_providers.codex_add_modal_key_placeholder': 'Please enter Codex API key',
'ai_providers.codex_add_modal_url_label': 'Base URL (Optional):',
'ai_providers.codex_add_modal_url_label': 'Base URL (Required):',
'ai_providers.codex_add_modal_url_placeholder': 'e.g.: https://api.example.com',
'ai_providers.codex_add_modal_proxy_label': 'Proxy URL (Optional):',
'ai_providers.codex_add_modal_proxy_placeholder': 'e.g.: socks5://proxy.example.com:1080',
'ai_providers.codex_edit_modal_title': 'Edit Codex API Configuration',
'ai_providers.codex_edit_modal_key_label': 'API Key:',
'ai_providers.codex_edit_modal_url_label': 'Base URL (Optional):',
'ai_providers.codex_edit_modal_url_label': 'Base URL (Required):',
'ai_providers.codex_edit_modal_proxy_label': 'Proxy URL (Optional):',
'ai_providers.codex_delete_confirm': 'Are you sure you want to delete this Codex configuration?',
@@ -614,6 +693,10 @@ const i18n = {
'ai_providers.claude_edit_modal_url_label': 'Base URL (Optional):',
'ai_providers.claude_edit_modal_proxy_label': 'Proxy URL (Optional):',
'ai_providers.claude_delete_confirm': 'Are you sure you want to delete this Claude configuration?',
'ai_providers.claude_models_label': 'Custom Models (Optional):',
'ai_providers.claude_models_hint': 'Leave empty to allow all models, or add name[, alias] entries to limit/alias them.',
'ai_providers.claude_models_add_btn': 'Add Model',
'ai_providers.claude_models_count': 'Models Count',
'ai_providers.openai_title': 'OpenAI Compatible Providers',
'ai_providers.openai_add_button': 'Add Provider',
@@ -624,10 +707,12 @@ const i18n = {
'ai_providers.openai_add_modal_name_placeholder': 'e.g.: openrouter',
'ai_providers.openai_add_modal_url_label': 'Base URL:',
'ai_providers.openai_add_modal_url_placeholder': 'e.g.: https://openrouter.ai/api/v1',
'ai_providers.openai_add_modal_keys_label': 'API Keys (one per line):',
'ai_providers.openai_add_modal_keys_placeholder': 'sk-key1\nsk-key2',
'ai_providers.openai_add_modal_keys_proxy_label': 'Proxy URL (one per line, optional):',
'ai_providers.openai_add_modal_keys_proxy_placeholder': 'socks5://proxy.example.com:1080\n',
'ai_providers.openai_add_modal_keys_label': 'API Keys',
'ai_providers.openai_edit_modal_keys_label': 'API Keys',
'ai_providers.openai_keys_hint': 'Add each key separately with an optional proxy URL to keep things organized.',
'ai_providers.openai_keys_add_btn': 'Add Key',
'ai_providers.openai_key_placeholder': 'sk-... key',
'ai_providers.openai_proxy_placeholder': 'Optional proxy URL (e.g. socks5://...)',
'ai_providers.openai_add_modal_models_label': 'Model List (name[, alias] one per line):',
'ai_providers.openai_models_hint': 'Example: gpt-4o-mini or moonshotai/kimi-k2:free, kimi-k2',
'ai_providers.openai_model_name_placeholder': 'Model name, e.g. moonshotai/kimi-k2:free',
@@ -636,8 +721,7 @@ const i18n = {
'ai_providers.openai_edit_modal_title': 'Edit OpenAI Compatible Provider',
'ai_providers.openai_edit_modal_name_label': 'Provider Name:',
'ai_providers.openai_edit_modal_url_label': 'Base URL:',
'ai_providers.openai_edit_modal_keys_label': 'API Keys (one per line):',
'ai_providers.openai_edit_modal_keys_proxy_label': 'Proxy URL (one per line, optional):',
'ai_providers.openai_edit_modal_keys_label': 'API Keys',
'ai_providers.openai_edit_modal_models_label': 'Model List (name[, alias] one per line):',
'ai_providers.openai_delete_confirm': 'Are you sure you want to delete this OpenAI provider?',
'ai_providers.openai_keys_count': 'Keys Count',
@@ -647,38 +731,74 @@ const i18n = {
// Auth files management
'auth_files.title': 'Auth Files Management',
'auth_files.title_section': 'Auth Files',
'auth_files.description': 'Here you can manage authentication configuration files for Qwen and Gemini. Upload JSON format authentication files to enable the corresponding AI services.',
'auth_files.description': 'Manage all CLI Proxy JSON auth files here (e.g. Qwen, Gemini, Vertex). Uploading a credential immediately enables the corresponding AI integration.',
'auth_files.upload_button': 'Upload File',
'auth_files.delete_all_button': 'Delete All',
'auth_files.empty_title': 'No Auth Files',
'auth_files.empty_desc': 'Click the button above to upload the first file',
'auth_files.search_empty_title': 'No matching files',
'auth_files.search_empty_desc': 'Try changing the filters or clearing the search box.',
'auth_files.file_size': 'Size',
'auth_files.file_modified': 'Modified',
'auth_files.download_button': 'Download',
'auth_files.delete_button': 'Delete',
'auth_files.delete_confirm': 'Are you sure you want to delete file',
'auth_files.delete_all_confirm': 'Are you sure you want to delete all auth files? This operation cannot be undone!',
'auth_files.delete_filtered_confirm': 'Are you sure you want to delete all {type} auth files? This operation cannot be undone!',
'auth_files.upload_error_json': 'Only JSON files are allowed',
'auth_files.upload_success': 'File uploaded successfully',
'auth_files.download_success': 'File downloaded successfully',
'auth_files.delete_success': 'File deleted successfully',
'auth_files.delete_all_success': 'Successfully deleted',
'auth_files.delete_filtered_success': 'Deleted {count} {type} auth files successfully',
'auth_files.delete_filtered_partial': '{type} auth files deletion finished: {success} succeeded, {failed} failed',
'auth_files.delete_filtered_none': 'No deletable auth files under the current filter ({type})',
'auth_files.files_count': 'files',
'auth_files.pagination_prev': 'Previous',
'auth_files.pagination_next': 'Next',
'auth_files.pagination_info': 'Page {current} / {total} · {count} files',
'auth_files.search_label': 'Search configs',
'auth_files.search_placeholder': 'Filter by name, type, or provider',
'auth_files.page_size_label': 'Per page',
'auth_files.page_size_unit': 'items',
'auth_files.filter_all': 'All',
'auth_files.filter_qwen': 'Qwen',
'auth_files.filter_gemini': 'Gemini',
'auth_files.filter_gemini-cli': 'GeminiCLI',
'auth_files.filter_aistudio': 'AIStudio',
'auth_files.filter_claude': 'Claude',
'auth_files.filter_codex': 'Codex',
'auth_files.filter_iflow': 'iFlow',
'auth_files.filter_vertex': 'Vertex',
'auth_files.filter_empty': 'Empty',
'auth_files.filter_unknown': 'Other',
'auth_files.type_qwen': 'Qwen',
'auth_files.type_gemini': 'Gemini',
'auth_files.type_gemini-cli': 'GeminiCLI',
'auth_files.type_aistudio': 'AIStudio',
'auth_files.type_claude': 'Claude',
'auth_files.type_codex': 'Codex',
'auth_files.type_iflow': 'iFlow',
'auth_files.type_vertex': 'Vertex',
'auth_files.type_empty': 'Empty',
'auth_files.type_unknown': 'Other',
'vertex_import.title': 'Vertex AI Credential Import',
'vertex_import.description': 'Upload a Google service account JSON to store it as auth-dir/vertex-<project>.json using the same rules as the CLI vertex-import helper.',
'vertex_import.location_label': 'Region (optional)',
'vertex_import.location_placeholder': 'us-central1',
'vertex_import.location_hint': 'Leave empty to use the default region us-central1.',
'vertex_import.file_label': 'Service account key JSON',
'vertex_import.file_hint': 'Only Google Cloud service account key JSON files are accepted.',
'vertex_import.file_placeholder': 'No file selected',
'vertex_import.choose_file': 'Choose File',
'vertex_import.import_button': 'Import Vertex Credential',
'vertex_import.file_required': 'Select a .json credential file first',
'vertex_import.success': 'Vertex credential imported successfully',
'vertex_import.result_title': 'Credential saved',
'vertex_import.result_project': 'Project ID',
'vertex_import.result_email': 'Service account',
'vertex_import.result_location': 'Region',
'vertex_import.result_file': 'Persisted file',
// Codex OAuth
'auth_login.codex_oauth_title': 'Codex OAuth',
@@ -761,6 +881,10 @@ const i18n = {
'usage_stats.by_hour': 'By Hour',
'usage_stats.by_day': 'By Day',
'usage_stats.refresh': 'Refresh',
'usage_stats.chart_line_label_1': 'Line 1',
'usage_stats.chart_line_label_2': 'Line 2',
'usage_stats.chart_line_label_3': 'Line 3',
'usage_stats.chart_line_hidden': 'Hide',
'usage_stats.no_data': 'No Data Available',
'usage_stats.loading_error': 'Loading Failed',
'usage_stats.api_endpoint': 'API Endpoint',
@@ -831,15 +955,21 @@ const i18n = {
'notification.quota_switch_preview_updated': 'Preview model switch settings updated',
'notification.usage_statistics_updated': 'Usage statistics settings updated',
'notification.logging_to_file_updated': 'Logging settings updated',
'notification.request_log_updated': 'Request logging setting updated',
'notification.ws_auth_updated': 'WebSocket authentication setting updated',
'notification.api_key_added': 'API key added successfully',
'notification.api_key_updated': 'API key updated successfully',
'notification.api_key_deleted': 'API key deleted successfully',
'notification.gemini_key_added': 'Gemini key added successfully',
'notification.gemini_key_updated': 'Gemini key updated successfully',
'notification.gemini_key_deleted': 'Gemini key deleted successfully',
'notification.gemini_multi_input_required': 'Please enter at least one Gemini key',
'notification.gemini_multi_failed': 'Gemini bulk add failed',
'notification.gemini_multi_summary': 'Gemini bulk add finished: {success} added, {skipped} skipped, {failed} failed',
'notification.codex_config_added': 'Codex configuration added successfully',
'notification.codex_config_updated': 'Codex configuration updated successfully',
'notification.codex_config_deleted': 'Codex configuration deleted successfully',
'notification.codex_base_url_required': 'Please enter the Codex Base URL',
'notification.claude_config_added': 'Claude configuration added successfully',
'notification.claude_config_updated': 'Claude configuration updated successfully',
'notification.claude_config_deleted': 'Claude configuration deleted successfully',
@@ -885,7 +1015,9 @@ const i18n = {
'sidebar.toggle_collapse': 'Collapse sidebar',
// Footer
'footer.version': 'Version',
'footer.api_version': 'CLI Proxy API Version',
'footer.build_date': 'Build Time',
'footer.version': 'Management UI Version',
'footer.author': 'Author'
}
},

View File

@@ -316,6 +316,32 @@
<span class="toggle-label"
data-i18n="basic_settings.logging_to_file_enable">启用日志记录到文件</span>
</div>
<div class="toggle-group">
<label class="toggle-switch">
<input type="checkbox" id="request-log-toggle">
<span class="slider"></span>
</label>
<span class="toggle-label"
data-i18n="basic_settings.request_log_enable">启用请求日志</span>
</div>
</div>
</div>
<!-- WebSocket 鉴权 -->
<div class="card">
<div class="card-header">
<h3><i class="fas fa-shield-alt"></i> <span
data-i18n="basic_settings.ws_auth_title">WebSocket 鉴权</span></h3>
</div>
<div class="card-content">
<div class="toggle-group">
<label class="toggle-switch">
<input type="checkbox" id="ws-auth-toggle">
<span class="slider"></span>
</label>
<span class="toggle-label"
data-i18n="basic_settings.ws_auth_enable">启用 /ws/* 鉴权</span>
</div>
</div>
</div>
@@ -402,6 +428,51 @@
<div id="openai-providers-list" class="provider-list"></div>
</div>
</div>
<!-- Vertex AI Credential Import -->
<div class="card" id="vertex-import-card">
<div class="card-header">
<h3><i class="fas fa-cloud-upload-alt"></i> <span data-i18n="vertex_import.title">Vertex AI 凭证导入</span></h3>
</div>
<div class="card-content">
<p class="form-hint" data-i18n="vertex_import.description">
上传 Google 服务账号 JSON 并保存为 vertex-<project>.json。
</p>
<div class="form-group">
<label for="vertex-location" data-i18n="vertex_import.location_label">区域 (可选)</label>
<input type="text" id="vertex-location" data-i18n-placeholder="vertex_import.location_placeholder" value="us-central1">
<p class="form-hint" data-i18n="vertex_import.location_hint">留空则使用默认 us-central1。</p>
</div>
<div class="form-group">
<label data-i18n="vertex_import.file_label">服务账号密钥 JSON</label>
<div class="input-group">
<input type="text" id="vertex-file-display" readonly data-i18n-placeholder="vertex_import.file_placeholder" placeholder="尚未选择文件">
<input type="file" id="vertex-file-input" accept=".json" style="display: none;">
<button type="button" id="vertex-select-file" class="btn btn-secondary">
<i class="fas fa-file-upload"></i> <span data-i18n="vertex_import.choose_file">选择文件</span>
</button>
</div>
<p class="form-hint" data-i18n="vertex_import.file_hint">仅支持 Google Cloud service account JSON。</p>
</div>
<div class="form-actions vertex-import-actions">
<button type="button" id="vertex-import-btn" class="btn btn-primary" disabled>
<i class="fas fa-cloud-upload-alt"></i> <span data-i18n="vertex_import.import_button">导入 Vertex 凭证</span>
</button>
</div>
<div id="vertex-import-result" class="vertex-import-result" style="display: none;">
<div class="vertex-import-result-header">
<i class="fas fa-check-circle"></i>
<span data-i18n="vertex_import.result_title">凭证已保存</span>
</div>
<ul>
<li><span data-i18n="vertex_import.result_project">项目 ID</span>: <code id="vertex-result-project">-</code></li>
<li><span data-i18n="vertex_import.result_email">服务账号</span>: <code id="vertex-result-email">-</code></li>
<li><span data-i18n="vertex_import.result_location">区域</span>: <code id="vertex-result-location">-</code></li>
<li><span data-i18n="vertex_import.result_file">存储文件</span>: <code id="vertex-result-file">-</code></li>
</ul>
</div>
</div>
</div>
</section>
<!-- 认证文件管理 -->
@@ -427,9 +498,12 @@
<button class="filter-btn active" data-type="all" data-i18n-text="auth_files.filter_all">All</button>
<button class="filter-btn" data-type="qwen" data-i18n-text="auth_files.filter_qwen">Qwen</button>
<button class="filter-btn" data-type="gemini" data-i18n-text="auth_files.filter_gemini">Gemini</button>
<button class="filter-btn" data-type="gemini-cli" data-i18n-text="auth_files.filter_gemini-cli">GeminiCLI</button>
<button class="filter-btn" data-type="aistudio" data-i18n-text="auth_files.filter_aistudio">AIStudio</button>
<button class="filter-btn" data-type="claude" data-i18n-text="auth_files.filter_claude">Claude</button>
<button class="filter-btn" data-type="codex" data-i18n-text="auth_files.filter_codex">Codex</button>
<button class="filter-btn" data-type="iflow" data-i18n-text="auth_files.filter_iflow">iFlow</button>
<button class="filter-btn" data-type="vertex" data-i18n-text="auth_files.filter_vertex">Vertex</button>
<button class="filter-btn" data-type="empty" data-i18n-text="auth_files.filter_empty">Empty</button>
</div>
</div>
@@ -445,7 +519,39 @@
</div>
</div>
<div class="card-content">
<div class="auth-file-toolbar">
<div class="auth-file-search-group">
<label for="auth-files-search-input"
data-i18n="auth_files.search_label">搜索配置文件</label>
<div class="auth-file-search">
<i class="fas fa-search"></i>
<input type="text" id="auth-files-search-input"
data-i18n-placeholder="auth_files.search_placeholder"
placeholder="搜索文件名或类型">
</div>
</div>
<div class="auth-file-page-size">
<label for="auth-files-page-size-input"
data-i18n="auth_files.page_size_label">单页数量</label>
<div class="page-size-input">
<input type="number" id="auth-files-page-size-input" min="3" max="60"
step="1">
<span data-i18n="auth_files.page_size_unit">个/页</span>
</div>
</div>
</div>
<div id="auth-files-list" class="file-list file-grid"></div>
<div id="auth-files-pagination" class="pagination-controls" style="display: none;">
<button class="btn btn-secondary pagination-btn" data-action="prev">
<i class="fas fa-chevron-left"></i>
<span data-i18n="auth_files.pagination_prev">上一页</span>
</button>
<div id="auth-files-pagination-info" class="pagination-info">-</div>
<button class="btn btn-secondary pagination-btn" data-action="next">
<span data-i18n="auth_files.pagination_next">下一页</span>
<i class="fas fa-chevron-right"></i>
</button>
</div>
<input type="file" id="auth-file-input" accept=".json" style="display: none;">
</div>
</div>
@@ -719,6 +825,28 @@
</div>
</div>
<!-- 图表曲线选择 -->
<div class="usage-filter-bar">
<div class="usage-filter-group">
<label for="chart-line-select-0" data-i18n="usage_stats.chart_line_label_1">曲线 1</label>
<select id="chart-line-select-0" class="model-filter-select chart-line-select" data-line-index="0" disabled>
<option value="none" data-i18n="usage_stats.chart_line_hidden">不显示</option>
</select>
</div>
<div class="usage-filter-group">
<label for="chart-line-select-1" data-i18n="usage_stats.chart_line_label_2">曲线 2</label>
<select id="chart-line-select-1" class="model-filter-select chart-line-select" data-line-index="1" disabled>
<option value="none" data-i18n="usage_stats.chart_line_hidden">不显示</option>
</select>
</div>
<div class="usage-filter-group">
<label for="chart-line-select-2" data-i18n="usage_stats.chart_line_label_3">曲线 3</label>
<select id="chart-line-select-2" class="model-filter-select chart-line-select" data-line-index="2" disabled>
<option value="none" data-i18n="usage_stats.chart_line_hidden">不显示</option>
</select>
</div>
</div>
<!-- 图表区域 -->
<div class="charts-container">
<!-- 请求趋势图 -->
@@ -875,7 +1003,14 @@
<!-- 版本信息 -->
<footer class="version-footer">
<div class="version-info">
<span data-i18n="footer.version">版本</span>: __VERSION__
<span data-i18n="footer.api_version">CLI Proxy API 版本</span>:
<span id="api-version">-</span>
<span class="separator"></span>
<span data-i18n="footer.build_date">构建时间</span>:
<span id="api-build-date">-</span>
<span class="separator"></span>
<span data-i18n="footer.version">管理中心版本</span>:
<span id="ui-version" data-ui-version="__VERSION__">-</span>
<span class="separator"></span>
<span data-i18n="footer.author">作者</span>: CLI Proxy API Team
</div>
@@ -900,7 +1035,7 @@
</div>
<script src="app.js"></script>
<script type="module" src="app.js"></script>
</body>
</html>

View File

@@ -3,10 +3,11 @@
"version": "1.0.0",
"description": "CLI Proxy API 管理界面",
"main": "index.html",
"type": "module",
"scripts": {
"start": "npx serve .",
"dev": "npx serve . --port 3000",
"build": "node build.js",
"dev": "npx serve . -l 3090",
"build": "node build.cjs",
"lint": "echo '使用浏览器开发者工具检查代码'"
},
"keywords": [
@@ -26,6 +27,5 @@
"repository": {
"type": "git",
"url": "local"
},
"dependencies": {}
}
}

503
src/core/connection.js Normal file
View File

@@ -0,0 +1,503 @@
// 连接与配置缓存核心模块
// 提供 API 基础地址规范化、请求封装、配置缓存以及统一数据加载能力
import { STATUS_UPDATE_INTERVAL_MS, DEFAULT_API_PORT } from '../utils/constants.js';
import { secureStorage } from '../utils/secure-storage.js';
export const connectionModule = {
// 规范化基础地址,移除尾部斜杠与 /v0/management
normalizeBase(input) {
let base = (input || '').trim();
if (!base) return '';
// 若用户粘贴了完整地址,剥离后缀
base = base.replace(/\/?v0\/management\/?$/i, '');
base = base.replace(/\/+$/i, '');
// 自动补 http://
if (!/^https?:\/\//i.test(base)) {
base = 'http://' + base;
}
return base;
},
// 由基础地址生成完整管理 API 地址
computeApiUrl(base) {
const b = this.normalizeBase(base);
if (!b) return '';
return b.replace(/\/$/, '') + '/v0/management';
},
setApiBase(newBase) {
this.apiBase = this.normalizeBase(newBase);
this.apiUrl = this.computeApiUrl(this.apiBase);
secureStorage.setItem('apiBase', this.apiBase);
secureStorage.setItem('apiUrl', this.apiUrl); // 兼容旧字段
this.updateLoginConnectionInfo();
},
// 加载设置(简化版,仅加载内部状态)
loadSettings() {
secureStorage.migratePlaintextKeys(['apiBase', 'apiUrl', 'managementKey']);
const savedBase = secureStorage.getItem('apiBase');
const savedUrl = secureStorage.getItem('apiUrl');
const savedKey = secureStorage.getItem('managementKey');
if (savedBase) {
this.setApiBase(savedBase);
} else if (savedUrl) {
const base = (savedUrl || '').replace(/\/?v0\/management\/?$/i, '');
this.setApiBase(base);
} else {
this.setApiBase(this.detectApiBaseFromLocation());
}
if (savedKey) {
this.managementKey = savedKey;
}
this.updateLoginConnectionInfo();
},
// 读取并填充管理中心版本号(可能来自构建时注入或占位符)
initUiVersion() {
const uiVersion = this.readUiVersionFromDom();
this.uiVersion = uiVersion;
this.renderVersionInfo();
},
// 从 DOM 获取版本占位符,并处理空值、引号或未替换的占位符
readUiVersionFromDom() {
const el = document.getElementById('ui-version');
if (!el) return null;
const raw = (el.dataset && el.dataset.uiVersion) ? el.dataset.uiVersion : el.textContent;
if (typeof raw !== 'string') return null;
const cleaned = raw.replace(/^"+|"+$/g, '').trim();
if (!cleaned || cleaned === '__VERSION__') {
return null;
}
return cleaned;
},
// 根据响应头更新版本与构建时间
updateVersionFromHeaders(headers) {
if (!headers || typeof headers.get !== 'function') {
return;
}
const version = headers.get('X-CPA-VERSION');
const buildDate = headers.get('X-CPA-BUILD-DATE');
let updated = false;
if (version && version !== this.serverVersion) {
this.serverVersion = version;
updated = true;
}
if (buildDate && buildDate !== this.serverBuildDate) {
this.serverBuildDate = buildDate;
updated = true;
}
if (updated) {
this.renderVersionInfo();
}
},
// 渲染底栏的版本与构建时间
renderVersionInfo() {
const versionEl = document.getElementById('api-version');
const buildDateEl = document.getElementById('api-build-date');
const uiVersionEl = document.getElementById('ui-version');
if (versionEl) {
versionEl.textContent = this.serverVersion || '-';
}
if (buildDateEl) {
buildDateEl.textContent = this.serverBuildDate
? this.formatBuildDate(this.serverBuildDate)
: '-';
}
if (uiVersionEl) {
const domVersion = this.readUiVersionFromDom();
uiVersionEl.textContent = this.uiVersion || domVersion || 'v0.0.0-dev';
}
},
// 清空版本信息(例如登出时)
resetVersionInfo() {
this.serverVersion = null;
this.serverBuildDate = null;
this.renderVersionInfo();
},
// 格式化构建时间,优先使用界面语言对应的本地格式
formatBuildDate(buildDate) {
if (!buildDate) return '-';
const parsed = Date.parse(buildDate);
if (!Number.isNaN(parsed)) {
const locale = i18n?.currentLanguage || undefined;
return new Date(parsed).toLocaleString(locale);
}
return buildDate;
},
// API 请求方法
async makeRequest(endpoint, options = {}) {
const url = `${this.apiUrl}${endpoint}`;
const headers = {
'Authorization': `Bearer ${this.managementKey}`,
'Content-Type': 'application/json',
...options.headers
};
try {
const response = await fetch(url, {
...options,
headers
});
this.updateVersionFromHeaders(response.headers);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `HTTP ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('API请求失败:', error);
throw error;
}
},
// 测试连接(简化版,用于内部调用)
async testConnection() {
try {
await this.makeRequest('/debug');
this.isConnected = true;
this.updateConnectionStatus();
this.startStatusUpdateTimer();
await this.loadAllData();
return true;
} catch (error) {
this.isConnected = false;
this.updateConnectionStatus();
this.stopStatusUpdateTimer();
throw error;
}
},
// 更新连接状态
updateConnectionStatus() {
const statusButton = document.getElementById('connection-status');
const apiStatus = document.getElementById('api-status');
const configStatus = document.getElementById('config-status');
const lastUpdate = document.getElementById('last-update');
if (this.isConnected) {
statusButton.innerHTML = `<i class="fas fa-circle connection-indicator connected"></i> ${i18n.t('common.connected')}`;
statusButton.className = 'btn btn-success';
apiStatus.textContent = i18n.t('common.connected');
// 更新配置状态
if (this.isCacheValid()) {
const fullTimestamp = this.cacheTimestamps && this.cacheTimestamps['__full__'];
const cacheAge = fullTimestamp
? Math.floor((Date.now() - fullTimestamp) / 1000)
: 0;
configStatus.textContent = `${i18n.t('system_info.cache_data')} (${cacheAge}${i18n.t('system_info.seconds_ago')})`;
configStatus.style.color = '#f59e0b'; // 橙色表示缓存
} else if (this.configCache && this.configCache['__full__']) {
configStatus.textContent = i18n.t('system_info.real_time_data');
configStatus.style.color = '#10b981'; // 绿色表示实时
} else {
configStatus.textContent = i18n.t('system_info.not_loaded');
configStatus.style.color = '#6b7280'; // 灰色表示未加载
}
} else {
statusButton.innerHTML = `<i class="fas fa-circle connection-indicator disconnected"></i> ${i18n.t('common.disconnected')}`;
statusButton.className = 'btn btn-danger';
apiStatus.textContent = i18n.t('common.disconnected');
configStatus.textContent = i18n.t('system_info.not_loaded');
configStatus.style.color = '#6b7280';
}
lastUpdate.textContent = new Date().toLocaleString('zh-CN');
if (this.lastEditorConnectionState !== this.isConnected) {
this.updateConfigEditorAvailability();
}
// 更新连接信息显示
this.updateConnectionInfo();
},
// 检查连接状态
async checkConnectionStatus() {
await this.testConnection();
},
// 刷新所有数据
async refreshAllData() {
if (!this.isConnected) {
this.showNotification(i18n.t('notification.connection_required'), 'error');
return;
}
const button = document.getElementById('refresh-all');
const originalText = button.innerHTML;
button.innerHTML = `<div class="loading"></div> ${i18n.t('common.loading')}`;
button.disabled = true;
try {
// 强制刷新,清除缓存
await this.loadAllData(true);
this.showNotification(i18n.t('notification.data_refreshed'), 'success');
} catch (error) {
this.showNotification(`${i18n.t('notification.refresh_failed')}: ${error.message}`, 'error');
} finally {
button.innerHTML = originalText;
button.disabled = false;
}
},
// 检查缓存是否有效
isCacheValid(section = null) {
if (section) {
// 检查特定配置段的缓存
// 注意:配置值可能是 false、0、'' 等 falsy 值,不能用 ! 判断
if (!(section in this.configCache) || !(section in this.cacheTimestamps)) {
return false;
}
return (Date.now() - this.cacheTimestamps[section]) < this.cacheExpiry;
}
// 检查全局缓存(兼容旧代码)
if (!this.configCache['__full__'] || !this.cacheTimestamps['__full__']) {
return false;
}
return (Date.now() - this.cacheTimestamps['__full__']) < this.cacheExpiry;
},
// 获取配置(优先使用缓存,支持按段获取)
async getConfig(section = null, forceRefresh = false) {
const now = Date.now();
// 如果请求特定配置段且该段缓存有效
if (section && !forceRefresh && this.isCacheValid(section)) {
this.updateConnectionStatus();
return this.configCache[section];
}
// 如果请求全部配置且全局缓存有效
if (!section && !forceRefresh && this.isCacheValid()) {
this.updateConnectionStatus();
return this.configCache['__full__'];
}
try {
const config = await this.makeRequest('/config');
if (section) {
// 缓存特定配置段
this.configCache[section] = config[section];
this.cacheTimestamps[section] = now;
// 同时更新全局缓存中的这一段
if (this.configCache['__full__']) {
this.configCache['__full__'][section] = config[section];
} else {
// 如果全局缓存不存在,也创建它
this.configCache['__full__'] = config;
this.cacheTimestamps['__full__'] = now;
}
this.updateConnectionStatus();
return config[section];
}
// 缓存全部配置
this.configCache['__full__'] = config;
this.cacheTimestamps['__full__'] = now;
// 同时缓存各个配置段
Object.keys(config).forEach(key => {
this.configCache[key] = config[key];
this.cacheTimestamps[key] = now;
});
this.updateConnectionStatus();
return config;
} catch (error) {
console.error('获取配置失败:', error);
throw error;
}
},
// 清除缓存(支持清除特定配置段)
clearCache(section = null) {
if (section) {
// 清除特定配置段的缓存
delete this.configCache[section];
delete this.cacheTimestamps[section];
// 同时清除全局缓存中的这一段
if (this.configCache['__full__']) {
delete this.configCache['__full__'][section];
}
} else {
// 清除所有缓存
this.configCache = {};
this.cacheTimestamps = {};
this.configYamlCache = '';
}
},
// 启动状态更新定时器
startStatusUpdateTimer() {
if (this.statusUpdateTimer) {
clearInterval(this.statusUpdateTimer);
}
this.statusUpdateTimer = setInterval(() => {
if (this.isConnected) {
this.updateConnectionStatus();
}
}, STATUS_UPDATE_INTERVAL_MS);
},
// 停止状态更新定时器
stopStatusUpdateTimer() {
if (this.statusUpdateTimer) {
clearInterval(this.statusUpdateTimer);
this.statusUpdateTimer = null;
}
},
// 加载所有数据 - 使用新的 /config 端点一次性获取所有配置
async loadAllData(forceRefresh = false) {
try {
console.log(i18n.t('system_info.real_time_data'));
// 使用新的 /config 端点一次性获取所有配置
// 注意getConfig(section, forceRefresh),不传 section 表示获取全部
const config = await this.getConfig(null, forceRefresh);
// 获取一次usage统计数据供渲染函数和loadUsageStats复用
let usageData = null;
let keyStats = null;
try {
const response = await this.makeRequest('/usage');
usageData = response?.usage || null;
if (usageData) {
keyStats = await this.getKeyStats(usageData);
}
} catch (error) {
console.warn('获取usage统计失败:', error);
}
// 从配置中提取并设置各个设置项现在传递keyStats
await this.updateSettingsFromConfig(config, keyStats);
// 认证文件需要单独加载,因为不在配置中
await this.loadAuthFiles(keyStats);
// 使用统计需要单独加载复用已获取的usage数据
await this.loadUsageStats(usageData);
// 加载配置文件编辑器内容
await this.loadConfigFileEditor(forceRefresh);
this.refreshConfigEditor();
console.log('配置加载完成,使用缓存:', !forceRefresh && this.isCacheValid());
} catch (error) {
console.error('加载配置失败:', error);
}
},
// 从配置对象更新所有设置
async updateSettingsFromConfig(config, keyStats = null) {
// 调试设置
if (config.debug !== undefined) {
document.getElementById('debug-toggle').checked = config.debug;
}
// 代理设置
if (config['proxy-url'] !== undefined) {
document.getElementById('proxy-url').value = config['proxy-url'] || '';
}
// 请求重试设置
if (config['request-retry'] !== undefined) {
document.getElementById('request-retry').value = config['request-retry'];
}
// 配额超出行为
if (config['quota-exceeded']) {
if (config['quota-exceeded']['switch-project'] !== undefined) {
document.getElementById('switch-project-toggle').checked = config['quota-exceeded']['switch-project'];
}
if (config['quota-exceeded']['switch-preview-model'] !== undefined) {
document.getElementById('switch-preview-model-toggle').checked = config['quota-exceeded']['switch-preview-model'];
}
}
if (config['usage-statistics-enabled'] !== undefined) {
const usageToggle = document.getElementById('usage-statistics-enabled-toggle');
if (usageToggle) {
usageToggle.checked = config['usage-statistics-enabled'];
}
}
// 日志记录设置
if (config['logging-to-file'] !== undefined) {
const loggingToggle = document.getElementById('logging-to-file-toggle');
if (loggingToggle) {
loggingToggle.checked = config['logging-to-file'];
}
// 显示或隐藏日志查看栏目
this.toggleLogsNavItem(config['logging-to-file']);
}
if (config['request-log'] !== undefined) {
const requestLogToggle = document.getElementById('request-log-toggle');
if (requestLogToggle) {
requestLogToggle.checked = config['request-log'];
}
}
if (config['ws-auth'] !== undefined) {
const wsAuthToggle = document.getElementById('ws-auth-toggle');
if (wsAuthToggle) {
wsAuthToggle.checked = config['ws-auth'];
}
}
// API 密钥
if (config['api-keys']) {
this.renderApiKeys(config['api-keys']);
}
// Gemini keys
await this.renderGeminiKeys(this.getGeminiKeysFromConfig(config), keyStats);
// Codex 密钥
await this.renderCodexKeys(Array.isArray(config['codex-api-key']) ? config['codex-api-key'] : [], keyStats);
// Claude 密钥
await this.renderClaudeKeys(Array.isArray(config['claude-api-key']) ? config['claude-api-key'] : [], keyStats);
// OpenAI 兼容提供商
await this.renderOpenAIProviders(Array.isArray(config['openai-compatibility']) ? config['openai-compatibility'] : [], keyStats);
},
detectApiBaseFromLocation() {
try {
const { protocol, hostname, port } = window.location;
const normalizedPort = port ? `:${port}` : '';
return this.normalizeBase(`${protocol}//${hostname}${normalizedPort}`);
} catch (error) {
console.warn('无法从当前地址检测 API 基础地址,使用默认设置', error);
return this.normalizeBase(this.apiBase || `http://localhost:${DEFAULT_API_PORT}`);
}
}
};

231
src/core/error-handler.js Normal file
View File

@@ -0,0 +1,231 @@
/**
* 错误处理器
* 统一管理应用中的错误处理逻辑
*/
import { ERROR_MESSAGES } from '../utils/constants.js';
/**
* 错误处理器类
* 提供统一的错误处理接口,确保错误处理的一致性
*/
export class ErrorHandler {
/**
* 构造错误处理器
* @param {Object} notificationService - 通知服务对象
* @param {Function} notificationService.show - 显示通知的方法
*/
constructor(notificationService) {
this.notificationService = notificationService;
}
/**
* 处理更新操作失败
* 包括显示错误通知和执行UI回滚操作
*
* @param {Error} error - 错误对象
* @param {string} context - 操作上下文(如"调试模式"、"代理设置"
* @param {Function} [rollbackFn] - UI回滚函数
*
* @example
* try {
* await this.makeRequest('/debug', { method: 'PATCH', body: JSON.stringify({ enabled: true }) });
* } catch (error) {
* this.errorHandler.handleUpdateError(
* error,
* '调试模式',
* () => document.getElementById('debug-toggle').checked = false
* );
* }
*/
handleUpdateError(error, context, rollbackFn) {
console.error(`更新${context}失败:`, error);
const message = `更新${context}失败: ${error.message || ERROR_MESSAGES.OPERATION_FAILED}`;
this.notificationService.show(message, 'error');
// 执行回滚操作
if (typeof rollbackFn === 'function') {
try {
rollbackFn();
} catch (rollbackError) {
console.error('UI回滚操作失败:', rollbackError);
}
}
}
/**
* 处理加载操作失败
*
* @param {Error} error - 错误对象
* @param {string} context - 加载内容的上下文(如"API密钥"、"使用统计"
*
* @example
* try {
* const data = await this.makeRequest('/api-keys');
* this.renderApiKeys(data);
* } catch (error) {
* this.errorHandler.handleLoadError(error, 'API密钥');
* }
*/
handleLoadError(error, context) {
console.error(`加载${context}失败:`, error);
const message = `加载${context}失败,请检查连接`;
this.notificationService.show(message, 'error');
}
/**
* 处理删除操作失败
*
* @param {Error} error - 错误对象
* @param {string} context - 删除内容的上下文
*/
handleDeleteError(error, context) {
console.error(`删除${context}失败:`, error);
const message = `删除${context}失败: ${error.message || ERROR_MESSAGES.OPERATION_FAILED}`;
this.notificationService.show(message, 'error');
}
/**
* 处理添加操作失败
*
* @param {Error} error - 错误对象
* @param {string} context - 添加内容的上下文
*/
handleAddError(error, context) {
console.error(`添加${context}失败:`, error);
const message = `添加${context}失败: ${error.message || ERROR_MESSAGES.OPERATION_FAILED}`;
this.notificationService.show(message, 'error');
}
/**
* 处理网络错误
* 检测常见的网络问题并提供友好的错误提示
*
* @param {Error} error - 错误对象
*/
handleNetworkError(error) {
console.error('网络请求失败:', error);
let message = ERROR_MESSAGES.NETWORK_ERROR;
// 检测特定错误类型
if (error.name === 'TypeError' && error.message.includes('fetch')) {
message = ERROR_MESSAGES.NETWORK_ERROR;
} else if (error.message && error.message.includes('timeout')) {
message = ERROR_MESSAGES.TIMEOUT;
} else if (error.message && error.message.includes('401')) {
message = ERROR_MESSAGES.UNAUTHORIZED;
} else if (error.message && error.message.includes('404')) {
message = ERROR_MESSAGES.NOT_FOUND;
} else if (error.message && error.message.includes('500')) {
message = ERROR_MESSAGES.SERVER_ERROR;
} else if (error.message) {
message = `网络错误: ${error.message}`;
}
this.notificationService.show(message, 'error');
}
/**
* 处理验证错误
*
* @param {string} fieldName - 字段名称
* @param {string} [message] - 自定义错误消息
*/
handleValidationError(fieldName, message) {
const errorMessage = message || `请输入有效的${fieldName}`;
this.notificationService.show(errorMessage, 'error');
}
/**
* 处理通用错误
* 当错误类型不明确时使用
*
* @param {Error} error - 错误对象
* @param {string} [defaultMessage] - 默认错误消息
*/
handleGenericError(error, defaultMessage) {
console.error('操作失败:', error);
const message = error.message || defaultMessage || ERROR_MESSAGES.OPERATION_FAILED;
this.notificationService.show(message, 'error');
}
/**
* 创建带错误处理的异步函数包装器
* 自动捕获并处理错误
*
* @param {Function} asyncFn - 异步函数
* @param {string} context - 操作上下文
* @param {Function} [rollbackFn] - 回滚函数
* @returns {Function} 包装后的函数
*
* @example
* const safeUpdate = this.errorHandler.withErrorHandling(
* () => this.makeRequest('/debug', { method: 'PATCH', body: '...' }),
* '调试模式',
* () => document.getElementById('debug-toggle').checked = false
* );
* await safeUpdate();
*/
withErrorHandling(asyncFn, context, rollbackFn) {
return async (...args) => {
try {
return await asyncFn(...args);
} catch (error) {
this.handleUpdateError(error, context, rollbackFn);
throw error; // 重新抛出以便调用者处理
}
};
}
/**
* 创建带重试机制的错误处理包装器
*
* @param {Function} asyncFn - 异步函数
* @param {number} [maxRetries=3] - 最大重试次数
* @param {number} [retryDelay=1000] - 重试延迟(毫秒)
* @returns {Function} 包装后的函数
*
* @example
* const retryableFetch = this.errorHandler.withRetry(
* () => this.makeRequest('/config'),
* 3,
* 2000
* );
* const config = await retryableFetch();
*/
withRetry(asyncFn, maxRetries = 3, retryDelay = 1000) {
return async (...args) => {
let lastError;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await asyncFn(...args);
} catch (error) {
lastError = error;
console.warn(`尝试 ${attempt + 1}/${maxRetries} 失败:`, error);
if (attempt < maxRetries - 1) {
// 等待后重试
await new Promise(resolve => setTimeout(resolve, retryDelay));
}
}
}
// 所有尝试都失败
throw lastError;
};
}
}
/**
* 创建错误处理器工厂函数
* 便于在不同模块中创建错误处理器实例
*
* @param {Function} showNotification - 显示通知的函数
* @returns {ErrorHandler} 错误处理器实例
*/
export function createErrorHandler(showNotification) {
return new ErrorHandler({
show: showNotification
});
}

1300
src/modules/ai-providers.js Normal file

File diff suppressed because it is too large Load Diff

340
src/modules/api-keys.js Normal file
View File

@@ -0,0 +1,340 @@
export const apiKeysModule = {
// 加载API密钥
async loadApiKeys() {
try {
const data = await this.makeRequest('/api-keys');
const apiKeysValue = data?.['api-keys'] || [];
const keys = Array.isArray(apiKeysValue) ? apiKeysValue : [];
this.renderApiKeys(keys);
} catch (error) {
console.error('加载API密钥失败:', error);
}
},
// 渲染API密钥列表
renderApiKeys(keys) {
const container = document.getElementById('api-keys-list');
if (keys.length === 0) {
container.innerHTML = `
<div class="empty-state">
<i class="fas fa-key"></i>
<h3>${i18n.t('api_keys.empty_title')}</h3>
<p>${i18n.t('api_keys.empty_desc')}</p>
</div>
`;
return;
}
container.innerHTML = keys.map((key, index) => {
const normalizedKey = typeof key === 'string' ? key : String(key ?? '');
const maskedDisplay = this.escapeHtml(this.maskApiKey(normalizedKey));
const keyArgument = JSON.stringify(normalizedKey).replace(/"/g, '&quot;');
return `
<div class="key-item">
<div class="item-content">
<div class="item-title">${i18n.t('api_keys.item_title')} #${index + 1}</div>
<div class="item-value">${maskedDisplay}</div>
</div>
<div class="item-actions">
<button class="btn btn-secondary" onclick="manager.editApiKey(${index}, ${keyArgument})">
<i class="fas fa-edit"></i>
</button>
<button class="btn btn-danger" onclick="manager.deleteApiKey(${index})">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
`;
}).join('');
},
// 注意: escapeHtml, maskApiKey, normalizeArrayResponse
// 现在由 app.js 通过工具模块提供,通过 this 访问
// 添加一行自定义请求头输入
addHeaderField(wrapperId, header = {}) {
const wrapper = document.getElementById(wrapperId);
if (!wrapper) return;
const row = document.createElement('div');
row.className = 'header-input-row';
const keyValue = typeof header.key === 'string' ? header.key : '';
const valueValue = typeof header.value === 'string' ? header.value : '';
row.innerHTML = `
<div class="input-group header-input-group">
<input type="text" class="header-key-input" placeholder="${i18n.t('common.custom_headers_key_placeholder')}" value="${this.escapeHtml(keyValue)}">
<span class="header-separator">:</span>
<input type="text" class="header-value-input" placeholder="${i18n.t('common.custom_headers_value_placeholder')}" value="${this.escapeHtml(valueValue)}">
<button type="button" class="btn btn-small btn-danger header-remove-btn"><i class="fas fa-trash"></i></button>
</div>
`;
const removeBtn = row.querySelector('.header-remove-btn');
if (removeBtn) {
removeBtn.addEventListener('click', () => {
wrapper.removeChild(row);
if (wrapper.childElementCount === 0) {
this.addHeaderField(wrapperId);
}
});
}
wrapper.appendChild(row);
},
// 填充自定义请求头输入
populateHeaderFields(wrapperId, headers = null) {
const wrapper = document.getElementById(wrapperId);
if (!wrapper) return;
wrapper.innerHTML = '';
const entries = (headers && typeof headers === 'object')
? Object.entries(headers).filter(([key, value]) => key && value !== undefined && value !== null)
: [];
if (!entries.length) {
this.addHeaderField(wrapperId);
return;
}
entries.forEach(([key, value]) => this.addHeaderField(wrapperId, { key, value: String(value ?? '') }));
},
// 收集自定义请求头输入
collectHeaderInputs(wrapperId) {
const wrapper = document.getElementById(wrapperId);
if (!wrapper) return null;
const rows = Array.from(wrapper.querySelectorAll('.header-input-row'));
const headers = {};
rows.forEach(row => {
const keyInput = row.querySelector('.header-key-input');
const valueInput = row.querySelector('.header-value-input');
const key = keyInput ? keyInput.value.trim() : '';
const value = valueInput ? valueInput.value.trim() : '';
if (key && value) {
headers[key] = value;
}
});
return Object.keys(headers).length ? headers : null;
},
addApiKeyEntryField(wrapperId, entry = {}) {
const wrapper = document.getElementById(wrapperId);
if (!wrapper) return;
const row = document.createElement('div');
row.className = 'api-key-input-row';
const keyValue = typeof entry?.['api-key'] === 'string' ? entry['api-key'] : '';
const proxyValue = typeof entry?.['proxy-url'] === 'string' ? entry['proxy-url'] : '';
row.innerHTML = `
<div class="input-group api-key-input-group">
<input type="text" class="api-key-value-input" placeholder="${i18n.t('ai_providers.openai_key_placeholder')}" value="${this.escapeHtml(keyValue)}">
<input type="text" class="api-key-proxy-input" placeholder="${i18n.t('ai_providers.openai_proxy_placeholder')}" value="${this.escapeHtml(proxyValue)}">
<button type="button" class="btn btn-small btn-danger api-key-remove-btn"><i class="fas fa-trash"></i></button>
</div>
`;
const removeBtn = row.querySelector('.api-key-remove-btn');
if (removeBtn) {
removeBtn.addEventListener('click', () => {
wrapper.removeChild(row);
if (wrapper.childElementCount === 0) {
this.addApiKeyEntryField(wrapperId);
}
});
}
wrapper.appendChild(row);
},
populateApiKeyEntryFields(wrapperId, entries = []) {
const wrapper = document.getElementById(wrapperId);
if (!wrapper) return;
wrapper.innerHTML = '';
if (!Array.isArray(entries) || entries.length === 0) {
this.addApiKeyEntryField(wrapperId);
return;
}
entries.forEach(entry => this.addApiKeyEntryField(wrapperId, entry));
},
collectApiKeyEntryInputs(wrapperId) {
const wrapper = document.getElementById(wrapperId);
if (!wrapper) return [];
const rows = Array.from(wrapper.querySelectorAll('.api-key-input-row'));
const entries = [];
rows.forEach(row => {
const keyInput = row.querySelector('.api-key-value-input');
const proxyInput = row.querySelector('.api-key-proxy-input');
const key = keyInput ? keyInput.value.trim() : '';
const proxy = proxyInput ? proxyInput.value.trim() : '';
if (key) {
entries.push({ 'api-key': key, 'proxy-url': proxy });
}
});
return entries;
},
// 规范化并写入请求头
applyHeadersToConfig(target, headers) {
if (!target) {
return;
}
if (headers && typeof headers === 'object' && Object.keys(headers).length) {
target.headers = { ...headers };
} else {
delete target.headers;
}
},
// 渲染请求头徽章
renderHeaderBadges(headers) {
if (!headers || typeof headers !== 'object') {
return '';
}
const entries = Object.entries(headers).filter(([key, value]) => key && value !== undefined && value !== null && value !== '');
if (!entries.length) {
return '';
}
const badges = entries.map(([key, value]) => `
<span class="header-badge"><strong>${this.escapeHtml(key)}:</strong> ${this.escapeHtml(String(value))}</span>
`).join('');
return `
<div class="item-subtitle header-badges-wrapper">
<span class="header-badges-label">${i18n.t('common.custom_headers_label')}:</span>
<div class="header-badge-list">
${badges}
</div>
</div>
`;
},
// 构造Codex配置保持未展示的字段
buildCodexConfig(apiKey, baseUrl, proxyUrl, original = {}, headers = null) {
const result = {
...original,
'api-key': apiKey,
'base-url': baseUrl || '',
'proxy-url': proxyUrl || ''
};
this.applyHeadersToConfig(result, headers);
return result;
},
// 显示添加API密钥模态框
showAddApiKeyModal() {
const modal = document.getElementById('modal');
const modalBody = document.getElementById('modal-body');
modalBody.innerHTML = `
<h3>${i18n.t('api_keys.add_modal_title')}</h3>
<div class="form-group">
<label for="new-api-key">${i18n.t('api_keys.add_modal_key_label')}</label>
<input type="text" id="new-api-key" placeholder="${i18n.t('api_keys.add_modal_key_placeholder')}">
</div>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="manager.closeModal()">${i18n.t('common.cancel')}</button>
<button class="btn btn-primary" onclick="manager.addApiKey()">${i18n.t('common.add')}</button>
</div>
`;
modal.style.display = 'block';
},
// 添加API密钥
async addApiKey() {
const newKey = document.getElementById('new-api-key').value.trim();
if (!newKey) {
this.showNotification(`${i18n.t('notification.please_enter')} ${i18n.t('notification.api_key')}`, 'error');
return;
}
try {
const data = await this.makeRequest('/api-keys');
const currentKeys = data['api-keys'] || [];
currentKeys.push(newKey);
await this.makeRequest('/api-keys', {
method: 'PUT',
body: JSON.stringify(currentKeys)
});
this.clearCache('api-keys'); // 仅清除 api-keys 段缓存
this.closeModal();
this.loadApiKeys();
this.showNotification(i18n.t('notification.api_key_added'), 'success');
} catch (error) {
this.showNotification(`${i18n.t('notification.add_failed')}: ${error.message}`, 'error');
}
},
// 编辑API密钥
editApiKey(index, currentKey) {
const modal = document.getElementById('modal');
const modalBody = document.getElementById('modal-body');
modalBody.innerHTML = `
<h3>${i18n.t('api_keys.edit_modal_title')}</h3>
<div class="form-group">
<label for="edit-api-key">${i18n.t('api_keys.edit_modal_key_label')}</label>
<input type="text" id="edit-api-key" value="${currentKey}">
</div>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="manager.closeModal()">${i18n.t('common.cancel')}</button>
<button class="btn btn-primary" onclick="manager.updateApiKey(${index})">${i18n.t('common.update')}</button>
</div>
`;
modal.style.display = 'block';
},
// 更新API密钥
async updateApiKey(index) {
const newKey = document.getElementById('edit-api-key').value.trim();
if (!newKey) {
this.showNotification(`${i18n.t('notification.please_enter')} ${i18n.t('notification.api_key')}`, 'error');
return;
}
try {
await this.makeRequest('/api-keys', {
method: 'PATCH',
body: JSON.stringify({ index, value: newKey })
});
this.clearCache('api-keys'); // 仅清除 api-keys 段缓存
this.closeModal();
this.loadApiKeys();
this.showNotification(i18n.t('notification.api_key_updated'), 'success');
} catch (error) {
this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error');
}
},
// 删除API密钥
async deleteApiKey(index) {
if (!confirm(i18n.t('api_keys.delete_confirm'))) return;
try {
await this.makeRequest(`/api-keys?index=${index}`, { method: 'DELETE' });
this.clearCache('api-keys'); // 仅清除 api-keys 段缓存
this.loadApiKeys();
this.showNotification(i18n.t('notification.api_key_deleted'), 'success');
} catch (error) {
this.showNotification(`${i18n.t('notification.delete_failed')}: ${error.message}`, 'error');
}
}
};

1045
src/modules/auth-files.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,262 @@
export const configEditorModule = {
setupConfigEditor() {
const textarea = document.getElementById('config-editor');
const saveBtn = document.getElementById('config-save-btn');
const reloadBtn = document.getElementById('config-reload-btn');
const statusEl = document.getElementById('config-editor-status');
this.configEditorElements = {
textarea,
editorInstance: null,
saveBtn,
reloadBtn,
statusEl
};
if (!textarea || !saveBtn || !reloadBtn || !statusEl) {
return;
}
if (window.CodeMirror) {
const editorInstance = window.CodeMirror.fromTextArea(textarea, {
mode: 'yaml',
theme: 'default',
lineNumbers: true,
indentUnit: 2,
tabSize: 2,
lineWrapping: true,
autoCloseBrackets: true,
extraKeys: {
'Ctrl-/': 'toggleComment',
'Cmd-/': 'toggleComment'
}
});
editorInstance.setSize('100%', '100%');
editorInstance.on('change', () => {
this.isConfigEditorDirty = true;
this.updateConfigEditorStatus('info', i18n.t('config_management.status_dirty'));
});
this.configEditorElements.editorInstance = editorInstance;
} else {
textarea.addEventListener('input', () => {
this.isConfigEditorDirty = true;
this.updateConfigEditorStatus('info', i18n.t('config_management.status_dirty'));
});
}
saveBtn.addEventListener('click', () => this.saveConfigFile());
reloadBtn.addEventListener('click', () => this.loadConfigFileEditor(true));
this.refreshConfigEditor();
},
updateConfigEditorAvailability() {
const { textarea, editorInstance, saveBtn, reloadBtn } = this.configEditorElements || {};
if ((!textarea && !editorInstance) || !saveBtn || !reloadBtn) {
return;
}
const disabled = !this.isConnected;
if (editorInstance) {
editorInstance.setOption('readOnly', disabled ? 'nocursor' : false);
const wrapper = editorInstance.getWrapperElement();
if (wrapper) {
wrapper.classList.toggle('cm-readonly', disabled);
}
} else if (textarea) {
textarea.disabled = disabled;
}
saveBtn.disabled = disabled;
reloadBtn.disabled = disabled;
if (disabled) {
this.updateConfigEditorStatus('info', i18n.t('config_management.status_disconnected'));
}
this.refreshConfigEditor();
this.lastEditorConnectionState = this.isConnected;
},
refreshConfigEditor() {
const instance = this.configEditorElements && this.configEditorElements.editorInstance;
if (instance && typeof instance.refresh === 'function') {
setTimeout(() => instance.refresh(), 0);
}
},
updateConfigEditorStatus(type, message) {
const statusEl = (this.configEditorElements && this.configEditorElements.statusEl) || document.getElementById('config-editor-status');
if (!statusEl) {
return;
}
statusEl.textContent = message;
statusEl.classList.remove('success', 'error');
if (type === 'success') {
statusEl.classList.add('success');
} else if (type === 'error') {
statusEl.classList.add('error');
}
},
async loadConfigFileEditor(forceRefresh = false) {
const { textarea, editorInstance, reloadBtn } = this.configEditorElements || {};
if (!textarea && !editorInstance) {
return;
}
if (!this.isConnected) {
this.updateConfigEditorStatus('info', i18n.t('config_management.status_disconnected'));
return;
}
if (reloadBtn) {
reloadBtn.disabled = true;
}
this.updateConfigEditorStatus('info', i18n.t('config_management.status_loading'));
try {
const yamlText = await this.fetchConfigFile(forceRefresh);
if (editorInstance) {
editorInstance.setValue(yamlText || '');
if (typeof editorInstance.markClean === 'function') {
editorInstance.markClean();
}
} else if (textarea) {
textarea.value = yamlText || '';
}
this.isConfigEditorDirty = false;
this.updateConfigEditorStatus('success', i18n.t('config_management.status_loaded'));
this.refreshConfigEditor();
} catch (error) {
console.error('加载配置文件失败:', error);
this.updateConfigEditorStatus('error', `${i18n.t('config_management.status_load_failed')}: ${error.message}`);
} finally {
if (reloadBtn) {
reloadBtn.disabled = !this.isConnected;
}
}
},
async fetchConfigFile(forceRefresh = false) {
if (!forceRefresh && this.configYamlCache) {
return this.configYamlCache;
}
const requestUrl = '/config.yaml';
try {
const response = await fetch(`${this.apiUrl}${requestUrl}`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${this.managementKey}`,
'Accept': 'application/yaml'
}
});
this.updateVersionFromHeaders(response.headers);
if (!response.ok) {
const errorText = await response.text().catch(() => '');
const message = errorText || `HTTP ${response.status}`;
throw new Error(message);
}
const contentType = response.headers.get('content-type') || '';
if (!/yaml/i.test(contentType)) {
throw new Error(i18n.t('config_management.error_yaml_not_supported'));
}
const text = await response.text();
this.lastConfigFetchUrl = requestUrl;
this.configYamlCache = text;
return text;
} catch (error) {
throw error instanceof Error ? error : new Error(String(error));
}
},
async saveConfigFile() {
const { textarea, editorInstance, saveBtn, reloadBtn } = this.configEditorElements || {};
if ((!textarea && !editorInstance) || !saveBtn) {
return;
}
if (!this.isConnected) {
this.updateConfigEditorStatus('error', i18n.t('config_management.status_disconnected'));
return;
}
const yamlText = editorInstance ? editorInstance.getValue() : (textarea ? textarea.value : '');
saveBtn.disabled = true;
if (reloadBtn) {
reloadBtn.disabled = true;
}
this.updateConfigEditorStatus('info', i18n.t('config_management.status_saving'));
try {
await this.writeConfigFile('/config.yaml', yamlText);
this.lastConfigFetchUrl = '/config.yaml';
this.configYamlCache = yamlText;
this.isConfigEditorDirty = false;
if (editorInstance && typeof editorInstance.markClean === 'function') {
editorInstance.markClean();
}
this.showNotification(i18n.t('config_management.save_success'), 'success');
this.updateConfigEditorStatus('success', i18n.t('config_management.status_saved'));
this.clearCache();
await this.loadAllData(true);
} catch (error) {
const errorMessage = `${i18n.t('config_management.status_save_failed')}: ${error.message}`;
this.updateConfigEditorStatus('error', errorMessage);
this.showNotification(errorMessage, 'error');
this.isConfigEditorDirty = true;
} finally {
saveBtn.disabled = !this.isConnected;
if (reloadBtn) {
reloadBtn.disabled = !this.isConnected;
}
}
},
async writeConfigFile(endpoint, yamlText) {
const response = await fetch(`${this.apiUrl}${endpoint}`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${this.managementKey}`,
'Content-Type': 'application/yaml',
'Accept': 'application/json, text/plain, */*'
},
body: yamlText
});
this.updateVersionFromHeaders(response.headers);
if (!response.ok) {
const contentType = response.headers.get('content-type') || '';
let errorText = '';
if (contentType.includes('application/json')) {
const data = await response.json().catch(() => ({}));
errorText = data.message || data.error || '';
} else {
errorText = await response.text().catch(() => '');
}
throw new Error(errorText || `HTTP ${response.status}`);
}
const contentType = response.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
const data = await response.json().catch(() => null);
if (data && data.ok === false) {
throw new Error(data.message || data.error || 'Server rejected the update');
}
}
}
};

26
src/modules/language.js Normal file
View File

@@ -0,0 +1,26 @@
export const languageModule = {
setupLanguageSwitcher() {
const loginToggle = document.getElementById('language-toggle');
const mainToggle = document.getElementById('language-toggle-main');
if (loginToggle) {
loginToggle.addEventListener('click', () => this.toggleLanguage());
}
if (mainToggle) {
mainToggle.addEventListener('click', () => this.toggleLanguage());
}
},
toggleLanguage() {
const currentLang = i18n.currentLanguage;
const newLang = currentLang === 'zh-CN' ? 'en-US' : 'zh-CN';
i18n.setLanguage(newLang);
this.updateThemeButtons();
this.updateConnectionStatus();
if (this.isLoggedIn && this.isConnected) {
this.loadAllData(true);
}
}
};

271
src/modules/login.js Normal file
View File

@@ -0,0 +1,271 @@
import { secureStorage } from '../utils/secure-storage.js';
export const loginModule = {
async checkLoginStatus() {
// 将旧的明文缓存迁移为加密格式
secureStorage.migratePlaintextKeys(['apiBase', 'apiUrl', 'managementKey']);
const savedBase = secureStorage.getItem('apiBase');
const savedKey = secureStorage.getItem('managementKey');
const wasLoggedIn = localStorage.getItem('isLoggedIn') === 'true';
if (savedBase && savedKey && wasLoggedIn) {
try {
console.log(i18n.t('auto_login.title'));
this.showAutoLoginLoading();
await this.attemptAutoLogin(savedBase, savedKey);
return;
} catch (error) {
console.log(`${i18n.t('notification.login_failed')}: ${error.message}`);
localStorage.removeItem('isLoggedIn');
this.hideAutoLoginLoading();
}
}
this.showLoginPage();
this.loadLoginSettings();
},
showAutoLoginLoading() {
document.getElementById('auto-login-loading').style.display = 'flex';
document.getElementById('login-page').style.display = 'none';
document.getElementById('main-page').style.display = 'none';
},
hideAutoLoginLoading() {
document.getElementById('auto-login-loading').style.display = 'none';
},
async attemptAutoLogin(apiBase, managementKey) {
try {
this.setApiBase(apiBase);
this.managementKey = managementKey;
const savedProxy = localStorage.getItem('proxyUrl');
if (savedProxy) {
// 代理设置会在后续的API请求中自动使用
}
await this.testConnection();
this.isLoggedIn = true;
this.hideAutoLoginLoading();
this.showMainPage();
console.log(i18n.t('auto_login.title'));
return true;
} catch (error) {
console.error('自动登录失败:', error);
this.isLoggedIn = false;
this.isConnected = false;
throw error;
}
},
showLoginPage() {
document.getElementById('login-page').style.display = 'flex';
document.getElementById('main-page').style.display = 'none';
this.isLoggedIn = false;
this.updateLoginConnectionInfo();
},
showMainPage() {
document.getElementById('login-page').style.display = 'none';
document.getElementById('main-page').style.display = 'block';
this.isLoggedIn = true;
this.updateConnectionInfo();
},
async login(apiBase, managementKey) {
try {
this.setApiBase(apiBase);
this.managementKey = managementKey;
secureStorage.setItem('managementKey', this.managementKey);
await this.testConnection();
this.isLoggedIn = true;
localStorage.setItem('isLoggedIn', 'true');
this.showMainPage();
return true;
} catch (error) {
console.error('登录失败:', error);
throw error;
}
},
logout() {
this.isLoggedIn = false;
this.isConnected = false;
this.clearCache();
this.stopStatusUpdateTimer();
this.resetVersionInfo();
localStorage.removeItem('isLoggedIn');
secureStorage.removeItem('managementKey');
this.showLoginPage();
},
async handleLogin() {
const apiBaseInput = document.getElementById('login-api-base');
const managementKeyInput = document.getElementById('login-management-key');
const managementKey = managementKeyInput ? managementKeyInput.value.trim() : '';
if (!managementKey) {
this.showLoginError(i18n.t('login.error_required'));
return;
}
if (apiBaseInput && apiBaseInput.value.trim()) {
this.setApiBase(apiBaseInput.value.trim());
}
const submitBtn = document.getElementById('login-submit');
const originalText = submitBtn ? submitBtn.innerHTML : '';
try {
if (submitBtn) {
submitBtn.innerHTML = `<div class=\"loading\"></div> ${i18n.t('login.submitting')}`;
submitBtn.disabled = true;
}
this.hideLoginError();
this.managementKey = managementKey;
secureStorage.setItem('managementKey', this.managementKey);
await this.login(this.apiBase, this.managementKey);
} catch (error) {
this.showLoginError(`${i18n.t('login.error_title')}: ${error.message}`);
} finally {
if (submitBtn) {
submitBtn.innerHTML = originalText;
submitBtn.disabled = false;
}
}
},
toggleLoginKeyVisibility(button) {
const inputGroup = button.closest('.input-group');
const keyInput = inputGroup.querySelector('input[type=\"password\"], input[type=\"text\"]');
if (keyInput.type === 'password') {
keyInput.type = 'text';
button.innerHTML = '<i class=\"fas fa-eye-slash\"></i>';
} else {
keyInput.type = 'password';
button.innerHTML = '<i class=\"fas fa-eye\"></i>';
}
},
showLoginError(message) {
const errorDiv = document.getElementById('login-error');
const errorMessage = document.getElementById('login-error-message');
errorMessage.textContent = message;
errorDiv.style.display = 'flex';
},
hideLoginError() {
const errorDiv = document.getElementById('login-error');
errorDiv.style.display = 'none';
},
updateConnectionInfo() {
const apiUrlElement = document.getElementById('display-api-url');
const statusElement = document.getElementById('display-connection-status');
if (apiUrlElement) {
apiUrlElement.textContent = this.apiBase || '-';
}
if (statusElement) {
let statusHtml = '';
if (this.isConnected) {
statusHtml = `<span class=\"status-indicator connected\"><i class=\"fas fa-circle\"></i> ${i18n.t('common.connected')}</span>`;
} else {
statusHtml = `<span class=\"status-indicator disconnected\"><i class=\"fas fa-circle\"></i> ${i18n.t('common.disconnected')}</span>`;
}
statusElement.innerHTML = statusHtml;
}
},
loadLoginSettings() {
const savedBase = secureStorage.getItem('apiBase');
const savedKey = secureStorage.getItem('managementKey');
const loginKeyInput = document.getElementById('login-management-key');
const apiBaseInput = document.getElementById('login-api-base');
if (savedBase) {
this.setApiBase(savedBase);
} else {
this.setApiBase(this.detectApiBaseFromLocation());
}
if (apiBaseInput) {
apiBaseInput.value = this.apiBase || '';
}
if (loginKeyInput && savedKey) {
loginKeyInput.value = savedKey;
}
this.setupLoginAutoSave();
},
setupLoginAutoSave() {
const loginKeyInput = document.getElementById('login-management-key');
const apiBaseInput = document.getElementById('login-api-base');
const resetButton = document.getElementById('login-reset-api-base');
const saveKey = (val) => {
if (val.trim()) {
this.managementKey = val;
secureStorage.setItem('managementKey', this.managementKey);
}
};
const saveKeyDebounced = this.debounce(saveKey, 500);
if (loginKeyInput) {
loginKeyInput.addEventListener('change', (e) => saveKey(e.target.value));
loginKeyInput.addEventListener('input', (e) => saveKeyDebounced(e.target.value));
}
if (apiBaseInput) {
const persistBase = (val) => {
const normalized = this.normalizeBase(val);
if (normalized) {
this.setApiBase(normalized);
}
};
const persistBaseDebounced = this.debounce(persistBase, 500);
apiBaseInput.addEventListener('change', (e) => persistBase(e.target.value));
apiBaseInput.addEventListener('input', (e) => persistBaseDebounced(e.target.value));
}
if (resetButton) {
resetButton.addEventListener('click', () => {
const detected = this.detectApiBaseFromLocation();
this.setApiBase(detected);
if (apiBaseInput) {
apiBaseInput.value = detected;
}
});
}
this.updateLoginConnectionInfo();
},
updateLoginConnectionInfo() {
const connectionUrlElement = document.getElementById('login-connection-url');
const customInput = document.getElementById('login-api-base');
if (connectionUrlElement) {
connectionUrlElement.textContent = this.apiBase || '-';
}
if (customInput && customInput !== document.activeElement) {
customInput.value = this.apiBase || '';
}
}
};

410
src/modules/logs.js Normal file
View File

@@ -0,0 +1,410 @@
export const logsModule = {
toggleLogsNavItem(show) {
const logsNavItem = document.getElementById('logs-nav-item');
if (logsNavItem) {
logsNavItem.style.display = show ? '' : 'none';
}
},
async refreshLogs(incremental = false) {
const logsContent = document.getElementById('logs-content');
if (!logsContent) return;
try {
if (incremental && !this.latestLogTimestamp) {
incremental = false;
}
if (!incremental) {
logsContent.innerHTML = '<div class="loading-placeholder" data-i18n="logs.loading">' + i18n.t('logs.loading') + '</div>';
}
let url = '/logs';
if (incremental && this.latestLogTimestamp) {
url += `?after=${this.latestLogTimestamp}`;
}
const response = await this.makeRequest(url, {
method: 'GET'
});
if (response && response.lines) {
if (response['latest-timestamp']) {
this.latestLogTimestamp = response['latest-timestamp'];
}
if (incremental && response.lines.length > 0) {
this.appendLogs(response.lines, response['line-count'] || 0);
} else if (!incremental && response.lines.length > 0) {
this.renderLogs(response.lines, response['line-count'] || response.lines.length, true);
} else if (!incremental) {
logsContent.innerHTML = '<div class="empty-state"><i class="fas fa-inbox"></i><p data-i18n="logs.empty_title">' +
i18n.t('logs.empty_title') + '</p><p data-i18n="logs.empty_desc">' +
i18n.t('logs.empty_desc') + '</p></div>';
this.latestLogTimestamp = null;
}
} else if (!incremental) {
logsContent.innerHTML = '<div class="empty-state"><i class="fas fa-inbox"></i><p data-i18n="logs.empty_title">' +
i18n.t('logs.empty_title') + '</p><p data-i18n="logs.empty_desc">' +
i18n.t('logs.empty_desc') + '</p></div>';
this.latestLogTimestamp = null;
}
} catch (error) {
console.error('加载日志失败:', error);
if (!incremental) {
const is404 = error.message && (error.message.includes('404') || error.message.includes('Not Found'));
if (is404) {
logsContent.innerHTML = '<div class="upgrade-notice"><i class="fas fa-arrow-circle-up"></i><h3 data-i18n="logs.upgrade_required_title">' +
i18n.t('logs.upgrade_required_title') + '</h3><p data-i18n="logs.upgrade_required_desc">' +
i18n.t('logs.upgrade_required_desc') + '</p></div>';
} else {
logsContent.innerHTML = '<div class="error-state"><i class="fas fa-exclamation-triangle"></i><p data-i18n="logs.load_error">' +
i18n.t('logs.load_error') + '</p><p>' + error.message + '</p></div>';
}
}
}
},
renderLogs(lines, lineCount, scrollToBottom = true) {
const logsContent = document.getElementById('logs-content');
if (!logsContent) return;
if (!lines || lines.length === 0) {
this.displayedLogLines = [];
logsContent.innerHTML = '<div class="empty-state"><i class="fas fa-inbox"></i><p data-i18n="logs.empty_title">' +
i18n.t('logs.empty_title') + '</p><p data-i18n="logs.empty_desc">' +
i18n.t('logs.empty_desc') + '</p></div>';
return;
}
const filteredLines = lines.filter(line => !line.includes('/v0/management/'));
let displayedLines = filteredLines;
if (filteredLines.length > this.maxDisplayLogLines) {
const linesToRemove = filteredLines.length - this.maxDisplayLogLines;
displayedLines = filteredLines.slice(linesToRemove);
}
this.displayedLogLines = displayedLines.slice();
const displayedLineCount = this.displayedLogLines.length;
logsContent.innerHTML = `
<div class="logs-info">
<span><i class="fas fa-list-ol"></i> ${displayedLineCount} ${i18n.t('logs.lines')}</span>
</div>
<pre class="logs-text">${this.buildLogsHtml(this.displayedLogLines)}</pre>
`;
if (scrollToBottom) {
const logsTextElement = logsContent.querySelector('.logs-text');
if (logsTextElement) {
logsTextElement.scrollTop = logsTextElement.scrollHeight;
}
}
},
appendLogs(newLines, totalLineCount) {
const logsContent = document.getElementById('logs-content');
if (!logsContent) return;
if (!newLines || newLines.length === 0) {
return;
}
const logsTextElement = logsContent.querySelector('.logs-text');
const logsInfoElement = logsContent.querySelector('.logs-info');
const filteredNewLines = newLines.filter(line => !line.includes('/v0/management/'));
if (filteredNewLines.length === 0) {
return;
}
if (!logsTextElement) {
this.renderLogs(filteredNewLines, totalLineCount || filteredNewLines.length, true);
return;
}
const isAtBottom = logsTextElement.scrollHeight - logsTextElement.scrollTop - logsTextElement.clientHeight < 50;
this.displayedLogLines = this.displayedLogLines.concat(filteredNewLines);
if (this.displayedLogLines.length > this.maxDisplayLogLines) {
this.displayedLogLines = this.displayedLogLines.slice(this.displayedLogLines.length - this.maxDisplayLogLines);
}
logsTextElement.innerHTML = this.buildLogsHtml(this.displayedLogLines);
if (logsInfoElement) {
const displayedLines = this.displayedLogLines.length;
logsInfoElement.innerHTML = `<span><i class="fas fa-list-ol"></i> ${displayedLines} ${i18n.t('logs.lines')}</span>`;
}
if (isAtBottom) {
logsTextElement.scrollTop = logsTextElement.scrollHeight;
}
},
buildLogsHtml(lines) {
if (!lines || lines.length === 0) {
return '';
}
return lines.map(line => {
let processedLine = line.replace(/\[GIN\]\s+\d{4}\/\d{2}\/\d{2}\s+-\s+\d{2}:\d{2}:\d{2}\s+/g, '');
const highlights = [];
const statusInfo = this.detectHttpStatus(line);
if (statusInfo) {
const statusPattern = new RegExp(`\\b${statusInfo.code}\\b`);
const match = statusPattern.exec(processedLine);
if (match) {
highlights.push({
start: match.index,
end: match.index + match[0].length,
className: `log-status-tag log-status-${statusInfo.bucket}`,
priority: 10
});
}
}
const timestampPattern = /\d{4}[-/]\d{2}[-/]\d{2}[T]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?|\[\d{2}:\d{2}:\d{2}\]/g;
let match;
while ((match = timestampPattern.exec(processedLine)) !== null) {
highlights.push({
start: match.index,
end: match.index + match[0].length,
className: 'log-timestamp',
priority: 5
});
}
const bracketTimestampPattern = /\[\d{4}[-/]\d{2}[-/]\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?\]/g;
while ((match = bracketTimestampPattern.exec(processedLine)) !== null) {
highlights.push({
start: match.index,
end: match.index + match[0].length,
className: 'log-timestamp',
priority: 5
});
}
const levelPattern = /\[(ERROR|ERRO|ERR|FATAL|CRITICAL|CRIT|WARN|WARNING|INFO|DEBUG|TRACE|PANIC)\]/gi;
while ((match = levelPattern.exec(processedLine)) !== null) {
const level = match[1].toUpperCase();
let className = 'log-level';
if (['ERROR', 'ERRO', 'ERR', 'FATAL', 'CRITICAL', 'CRIT', 'PANIC'].includes(level)) {
className += ' log-level-error';
} else if (['WARN', 'WARNING'].includes(level)) {
className += ' log-level-warn';
} else if (level === 'INFO') {
className += ' log-level-info';
} else if (['DEBUG', 'TRACE'].includes(level)) {
className += ' log-level-debug';
}
highlights.push({
start: match.index,
end: match.index + match[0].length,
className,
priority: 8
});
}
const methodPattern = /\b(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS|CONNECT|TRACE)\b/g;
while ((match = methodPattern.exec(processedLine)) !== null) {
highlights.push({
start: match.index,
end: match.index + match[0].length,
className: 'log-http-method',
priority: 6
});
}
const urlPattern = /(https?:\/\/[^\s<>"']+)/g;
while ((match = urlPattern.exec(processedLine)) !== null) {
highlights.push({
start: match.index,
end: match.index + match[0].length,
className: 'log-path',
priority: 4
});
}
const ipPattern = /\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b/g;
while ((match = ipPattern.exec(processedLine)) !== null) {
highlights.push({
start: match.index,
end: match.index + match[0].length,
className: 'log-ip',
priority: 7
});
}
const successPattern = /\b(success|successful|succeeded|completed|ok|done|passed)\b/gi;
while ((match = successPattern.exec(processedLine)) !== null) {
highlights.push({
start: match.index,
end: match.index + match[0].length,
className: 'log-keyword-success',
priority: 3
});
}
const errorPattern = /\b(failed|failure|error|exception|panic|fatal|critical|aborted|denied|refused|timeout|invalid)\b/gi;
while ((match = errorPattern.exec(processedLine)) !== null) {
highlights.push({
start: match.index,
end: match.index + match[0].length,
className: 'log-keyword-error',
priority: 3
});
}
const headersPattern = /\b(x-[a-z0-9-]+|authorization|content-type|user-agent)\b/gi;
while ((match = headersPattern.exec(processedLine)) !== null) {
highlights.push({
start: match.index,
end: match.index + match[0].length,
className: 'log-header-key',
priority: 2
});
}
highlights.sort((a, b) => {
if (a.start === b.start) {
return b.priority - a.priority;
}
return a.start - b.start;
});
let cursor = 0;
let result = '';
highlights.forEach((highlight) => {
if (highlight.start < cursor) {
return;
}
result += this.escapeHtml(processedLine.slice(cursor, highlight.start));
result += `<span class="${highlight.className}">${this.escapeHtml(processedLine.slice(highlight.start, highlight.end))}</span>`;
cursor = highlight.end;
});
result += this.escapeHtml(processedLine.slice(cursor));
return `<span class="log-line">${result}</span>`;
}).join('');
},
detectHttpStatus(line) {
if (!line) return null;
const patterns = [
/\|\s*([1-5]\d{2})\s*\|/,
/\b([1-5]\d{2})\s*-/,
/\b(?:GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS|CONNECT|TRACE)\s+\S+\s+([1-5]\d{2})\b/,
/\b(?:status|code|http)[:\s]+([1-5]\d{2})\b/i,
/\b([1-5]\d{2})\s+(?:OK|Created|Accepted|No Content|Moved|Found|Bad Request|Unauthorized|Forbidden|Not Found|Method Not Allowed|Internal Server Error|Bad Gateway|Service Unavailable|Gateway Timeout)\b/i
];
for (const pattern of patterns) {
const match = line.match(pattern);
if (match) {
const code = parseInt(match[1], 10);
if (Number.isNaN(code)) {
continue;
}
if (code >= 500) {
return { code, bucket: '5xx', match: match[1] };
}
if (code >= 400) {
return { code, bucket: '4xx', match: match[1] };
}
if (code >= 300) {
return { code, bucket: '3xx', match: match[1] };
}
if (code >= 200) {
return { code, bucket: '2xx', match: match[1] };
}
if (code >= 100) {
return { code, bucket: '1xx', match: match[1] };
}
}
}
return null;
},
async downloadLogs() {
try {
const response = await this.makeRequest('/logs', {
method: 'GET'
});
if (response && response.lines && response.lines.length > 0) {
const logsText = response.lines.join('\n');
const blob = new Blob([logsText], { type: 'text/plain' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `cli-proxy-api-logs-${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.log`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
this.showNotification(i18n.t('logs.download_success'), 'success');
} else {
this.showNotification(i18n.t('logs.empty_title'), 'info');
}
} catch (error) {
console.error('下载日志失败:', error);
this.showNotification(`${i18n.t('notification.download_failed')}: ${error.message}`, 'error');
}
},
async clearLogs() {
if (!confirm(i18n.t('logs.clear_confirm'))) {
return;
}
try {
const response = await this.makeRequest('/logs', {
method: 'DELETE'
});
if (response && response.status === 'ok') {
const removedCount = response.removed || 0;
const message = `${i18n.t('logs.clear_success')} (${i18n.t('logs.removed')}: ${removedCount} ${i18n.t('logs.lines')})`;
this.showNotification(message, 'success');
} else {
this.showNotification(i18n.t('logs.clear_success'), 'success');
}
this.latestLogTimestamp = null;
await this.refreshLogs(false);
} catch (error) {
console.error('清空日志失败:', error);
this.showNotification(`${i18n.t('notification.delete_failed')}: ${error.message}`, 'error');
}
},
toggleLogsAutoRefresh(enabled) {
if (enabled) {
if (this.logsRefreshTimer) {
clearInterval(this.logsRefreshTimer);
}
this.logsRefreshTimer = setInterval(() => {
const logsSection = document.getElementById('logs');
if (logsSection && logsSection.classList.contains('active')) {
this.refreshLogs(true);
}
}, 5000);
this.showNotification(i18n.t('logs.auto_refresh_enabled'), 'success');
} else {
if (this.logsRefreshTimer) {
clearInterval(this.logsRefreshTimer);
this.logsRefreshTimer = null;
}
this.showNotification(i18n.t('logs.auto_refresh_disabled'), 'info');
}
}
};

102
src/modules/navigation.js Normal file
View File

@@ -0,0 +1,102 @@
export const navigationModule = {
setupNavigation() {
const navItems = document.querySelectorAll('.nav-item');
navItems.forEach(item => {
item.addEventListener('click', (e) => {
e.preventDefault();
navItems.forEach(nav => nav.classList.remove('active'));
document.querySelectorAll('.content-section').forEach(section => section.classList.remove('active'));
item.classList.add('active');
const sectionId = item.getAttribute('data-section');
const section = document.getElementById(sectionId);
if (section) {
section.classList.add('active');
}
if (sectionId === 'logs') {
this.refreshLogs(false);
} else if (sectionId === 'config-management') {
this.loadConfigFileEditor();
this.refreshConfigEditor();
}
});
});
},
toggleMobileSidebar() {
const sidebar = document.getElementById('sidebar');
const overlay = document.getElementById('sidebar-overlay');
const layout = document.getElementById('layout-container');
const mainWrapper = document.getElementById('main-wrapper');
if (sidebar && overlay) {
const isOpen = sidebar.classList.toggle('mobile-open');
overlay.classList.toggle('active');
if (layout) {
layout.classList.toggle('sidebar-open', isOpen);
}
if (mainWrapper) {
mainWrapper.classList.toggle('sidebar-open', isOpen);
}
}
},
closeMobileSidebar() {
const sidebar = document.getElementById('sidebar');
const overlay = document.getElementById('sidebar-overlay');
const layout = document.getElementById('layout-container');
const mainWrapper = document.getElementById('main-wrapper');
if (sidebar && overlay) {
sidebar.classList.remove('mobile-open');
overlay.classList.remove('active');
if (layout) {
layout.classList.remove('sidebar-open');
}
if (mainWrapper) {
mainWrapper.classList.remove('sidebar-open');
}
}
},
toggleSidebar() {
const sidebar = document.getElementById('sidebar');
const layout = document.getElementById('layout-container');
if (sidebar && layout) {
const isCollapsed = sidebar.classList.toggle('collapsed');
layout.classList.toggle('sidebar-collapsed', isCollapsed);
localStorage.setItem('sidebarCollapsed', isCollapsed ? 'true' : 'false');
const toggleBtn = document.getElementById('sidebar-toggle-btn-desktop');
if (toggleBtn) {
toggleBtn.setAttribute('data-i18n-title', isCollapsed ? 'sidebar.toggle_expand' : 'sidebar.toggle_collapse');
toggleBtn.title = i18n.t(isCollapsed ? 'sidebar.toggle_expand' : 'sidebar.toggle_collapse');
}
}
},
restoreSidebarState() {
if (window.innerWidth > 1024) {
const savedState = localStorage.getItem('sidebarCollapsed');
if (savedState === 'true') {
const sidebar = document.getElementById('sidebar');
const layout = document.getElementById('layout-container');
if (sidebar && layout) {
sidebar.classList.add('collapsed');
layout.classList.add('sidebar-collapsed');
const toggleBtn = document.getElementById('sidebar-toggle-btn-desktop');
if (toggleBtn) {
toggleBtn.setAttribute('data-i18n-title', 'sidebar.toggle_expand');
toggleBtn.title = i18n.t('sidebar.toggle_expand');
}
}
}
}
}
};

738
src/modules/oauth.js Normal file
View File

@@ -0,0 +1,738 @@
export const oauthModule = {
// ===== Codex OAuth 相关方法 =====
// 开始 Codex OAuth 流程
async startCodexOAuth() {
try {
const response = await this.makeRequest('/codex-auth-url?is_webui=1');
const authUrl = response.url;
const state = this.extractStateFromUrl(authUrl);
// 显示授权链接
const urlInput = document.getElementById('codex-oauth-url');
const content = document.getElementById('codex-oauth-content');
const status = document.getElementById('codex-oauth-status');
if (urlInput) {
urlInput.value = authUrl;
}
if (content) {
content.style.display = 'block';
}
if (status) {
status.textContent = i18n.t('auth_login.codex_oauth_status_waiting');
status.style.color = 'var(--warning-text)';
}
// 开始轮询认证状态
this.startCodexOAuthPolling(state);
} catch (error) {
this.showNotification(`${i18n.t('auth_login.codex_oauth_start_error')} ${error.message}`, 'error');
}
},
// 从 URL 中提取 state 参数
extractStateFromUrl(url) {
try {
const urlObj = new URL(url);
return urlObj.searchParams.get('state');
} catch (error) {
console.error('Failed to extract state from URL:', error);
return null;
}
},
// 打开 Codex 授权链接
openCodexLink() {
const urlInput = document.getElementById('codex-oauth-url');
if (urlInput && urlInput.value) {
window.open(urlInput.value, '_blank');
}
},
// 复制 Codex 授权链接
async copyCodexLink() {
const urlInput = document.getElementById('codex-oauth-url');
if (urlInput && urlInput.value) {
try {
await navigator.clipboard.writeText(urlInput.value);
this.showNotification(i18n.t('notification.link_copied'), 'success');
} catch (error) {
// 降级方案:使用传统的复制方法
urlInput.select();
document.execCommand('copy');
this.showNotification(i18n.t('notification.link_copied'), 'success');
}
}
},
// 开始轮询 OAuth 状态
startCodexOAuthPolling(state) {
if (!state) {
this.showNotification(i18n.t('auth_login.missing_state'), 'error');
return;
}
const pollInterval = setInterval(async () => {
try {
const response = await this.makeRequest(`/get-auth-status?state=${encodeURIComponent(state)}`);
const status = response.status;
const statusElement = document.getElementById('codex-oauth-status');
if (status === 'ok') {
// 认证成功
clearInterval(pollInterval);
// 隐藏授权链接相关内容,恢复到初始状态
this.resetCodexOAuthUI();
// 显示成功通知
this.showNotification(i18n.t('auth_login.codex_oauth_status_success'), 'success');
// 刷新认证文件列表
this.loadAuthFiles();
} else if (status === 'error') {
// 认证失败
clearInterval(pollInterval);
const errorMessage = response.error || 'Unknown error';
// 显示错误信息
if (statusElement) {
statusElement.textContent = `${i18n.t('auth_login.codex_oauth_status_error')} ${errorMessage}`;
statusElement.style.color = 'var(--error-text)';
}
this.showNotification(`${i18n.t('auth_login.codex_oauth_status_error')} ${errorMessage}`, 'error');
// 3秒后重置UI让用户能够重新开始
setTimeout(() => {
this.resetCodexOAuthUI();
}, 3000);
} else if (status === 'wait') {
// 继续等待
if (statusElement) {
statusElement.textContent = i18n.t('auth_login.codex_oauth_status_waiting');
statusElement.style.color = 'var(--warning-text)';
}
}
} catch (error) {
clearInterval(pollInterval);
const statusElement = document.getElementById('codex-oauth-status');
if (statusElement) {
statusElement.textContent = `${i18n.t('auth_login.codex_oauth_polling_error')} ${error.message}`;
statusElement.style.color = 'var(--error-text)';
}
this.showNotification(`${i18n.t('auth_login.codex_oauth_polling_error')} ${error.message}`, 'error');
// 3秒后重置UI让用户能够重新开始
setTimeout(() => {
this.resetCodexOAuthUI();
}, 3000);
}
}, 2000); // 每2秒轮询一次
// 设置超时5分钟后停止轮询
setTimeout(() => {
clearInterval(pollInterval);
}, 5 * 60 * 1000);
},
// 重置 Codex OAuth UI 到初始状态
resetCodexOAuthUI() {
const urlInput = document.getElementById('codex-oauth-url');
const content = document.getElementById('codex-oauth-content');
const status = document.getElementById('codex-oauth-status');
// 清空并隐藏授权链接输入框
if (urlInput) {
urlInput.value = '';
}
// 隐藏整个授权链接内容区域
if (content) {
content.style.display = 'none';
}
// 清空状态显示
if (status) {
status.textContent = '';
status.style.color = '';
status.className = '';
}
},
// ===== Anthropic OAuth 相关方法 =====
// 开始 Anthropic OAuth 流程
async startAnthropicOAuth() {
try {
const response = await this.makeRequest('/anthropic-auth-url?is_webui=1');
const authUrl = response.url;
const state = this.extractStateFromUrl(authUrl);
// 显示授权链接
const urlInput = document.getElementById('anthropic-oauth-url');
const content = document.getElementById('anthropic-oauth-content');
const status = document.getElementById('anthropic-oauth-status');
if (urlInput) {
urlInput.value = authUrl;
}
if (content) {
content.style.display = 'block';
}
if (status) {
status.textContent = i18n.t('auth_login.anthropic_oauth_status_waiting');
status.style.color = 'var(--warning-text)';
}
// 开始轮询认证状态
this.startAnthropicOAuthPolling(state);
} catch (error) {
this.showNotification(`${i18n.t('auth_login.anthropic_oauth_start_error')} ${error.message}`, 'error');
}
},
// 打开 Anthropic 授权链接
openAnthropicLink() {
const urlInput = document.getElementById('anthropic-oauth-url');
if (urlInput && urlInput.value) {
window.open(urlInput.value, '_blank');
}
},
// 复制 Anthropic 授权链接
async copyAnthropicLink() {
const urlInput = document.getElementById('anthropic-oauth-url');
if (urlInput && urlInput.value) {
try {
await navigator.clipboard.writeText(urlInput.value);
this.showNotification(i18n.t('notification.link_copied'), 'success');
} catch (error) {
// 降级方案:使用传统的复制方法
urlInput.select();
document.execCommand('copy');
this.showNotification(i18n.t('notification.link_copied'), 'success');
}
}
},
// 开始轮询 Anthropic OAuth 状态
startAnthropicOAuthPolling(state) {
if (!state) {
this.showNotification(i18n.t('auth_login.missing_state'), 'error');
return;
}
const pollInterval = setInterval(async () => {
try {
const response = await this.makeRequest(`/get-auth-status?state=${encodeURIComponent(state)}`);
const status = response.status;
const statusElement = document.getElementById('anthropic-oauth-status');
if (status === 'ok') {
// 认证成功
clearInterval(pollInterval);
// 隐藏授权链接相关内容,恢复到初始状态
this.resetAnthropicOAuthUI();
// 显示成功通知
this.showNotification(i18n.t('auth_login.anthropic_oauth_status_success'), 'success');
// 刷新认证文件列表
this.loadAuthFiles();
} else if (status === 'error') {
// 认证失败
clearInterval(pollInterval);
const errorMessage = response.error || 'Unknown error';
// 显示错误信息
if (statusElement) {
statusElement.textContent = `${i18n.t('auth_login.anthropic_oauth_status_error')} ${errorMessage}`;
statusElement.style.color = 'var(--error-text)';
}
this.showNotification(`${i18n.t('auth_login.anthropic_oauth_status_error')} ${errorMessage}`, 'error');
// 3秒后重置UI让用户能够重新开始
setTimeout(() => {
this.resetAnthropicOAuthUI();
}, 3000);
} else if (status === 'wait') {
// 继续等待
if (statusElement) {
statusElement.textContent = i18n.t('auth_login.anthropic_oauth_status_waiting');
statusElement.style.color = 'var(--warning-text)';
}
}
} catch (error) {
clearInterval(pollInterval);
const statusElement = document.getElementById('anthropic-oauth-status');
if (statusElement) {
statusElement.textContent = `${i18n.t('auth_login.anthropic_oauth_polling_error')} ${error.message}`;
statusElement.style.color = 'var(--error-text)';
}
this.showNotification(`${i18n.t('auth_login.anthropic_oauth_polling_error')} ${error.message}`, 'error');
// 3秒后重置UI让用户能够重新开始
setTimeout(() => {
this.resetAnthropicOAuthUI();
}, 3000);
}
}, 2000); // 每2秒轮询一次
// 设置超时5分钟后停止轮询
setTimeout(() => {
clearInterval(pollInterval);
}, 5 * 60 * 1000);
},
// 重置 Anthropic OAuth UI 到初始状态
resetAnthropicOAuthUI() {
const urlInput = document.getElementById('anthropic-oauth-url');
const content = document.getElementById('anthropic-oauth-content');
const status = document.getElementById('anthropic-oauth-status');
// 清空并隐藏授权链接输入框
if (urlInput) {
urlInput.value = '';
}
// 隐藏整个授权链接内容区域
if (content) {
content.style.display = 'none';
}
// 清空状态显示
if (status) {
status.textContent = '';
status.style.color = '';
status.className = '';
}
},
// ===== Gemini CLI OAuth 相关方法 =====
// 开始 Gemini CLI OAuth 流程
async startGeminiCliOAuth() {
try {
const response = await this.makeRequest('/gemini-cli-auth-url?is_webui=1');
const authUrl = response.url;
const state = this.extractStateFromUrl(authUrl);
// 显示授权链接
const urlInput = document.getElementById('gemini-cli-oauth-url');
const content = document.getElementById('gemini-cli-oauth-content');
const status = document.getElementById('gemini-cli-oauth-status');
if (urlInput) {
urlInput.value = authUrl;
}
if (content) {
content.style.display = 'block';
}
if (status) {
status.textContent = i18n.t('auth_login.gemini_cli_oauth_status_waiting');
status.style.color = 'var(--warning-text)';
}
// 开始轮询认证状态
this.startGeminiCliOAuthPolling(state);
} catch (error) {
this.showNotification(`${i18n.t('auth_login.gemini_cli_oauth_start_error')} ${error.message}`, 'error');
}
},
// 打开 Gemini CLI 授权链接
openGeminiCliLink() {
const urlInput = document.getElementById('gemini-cli-oauth-url');
if (urlInput && urlInput.value) {
window.open(urlInput.value, '_blank');
}
},
// 复制 Gemini CLI 授权链接
async copyGeminiCliLink() {
const urlInput = document.getElementById('gemini-cli-oauth-url');
if (urlInput && urlInput.value) {
try {
await navigator.clipboard.writeText(urlInput.value);
this.showNotification(i18n.t('notification.link_copied'), 'success');
} catch (error) {
// 降级方案:使用传统的复制方法
urlInput.select();
document.execCommand('copy');
this.showNotification(i18n.t('notification.link_copied'), 'success');
}
}
},
// 开始轮询 Gemini CLI OAuth 状态
startGeminiCliOAuthPolling(state) {
if (!state) {
this.showNotification(i18n.t('auth_login.missing_state'), 'error');
return;
}
const pollInterval = setInterval(async () => {
try {
const response = await this.makeRequest(`/get-auth-status?state=${encodeURIComponent(state)}`);
const status = response.status;
const statusElement = document.getElementById('gemini-cli-oauth-status');
if (status === 'ok') {
// 认证成功
clearInterval(pollInterval);
// 隐藏授权链接相关内容,恢复到初始状态
this.resetGeminiCliOAuthUI();
// 显示成功通知
this.showNotification(i18n.t('auth_login.gemini_cli_oauth_status_success'), 'success');
// 刷新认证文件列表
this.loadAuthFiles();
} else if (status === 'error') {
// 认证失败
clearInterval(pollInterval);
const errorMessage = response.error || 'Unknown error';
// 显示错误信息
if (statusElement) {
statusElement.textContent = `${i18n.t('auth_login.gemini_cli_oauth_status_error')} ${errorMessage}`;
statusElement.style.color = 'var(--error-text)';
}
this.showNotification(`${i18n.t('auth_login.gemini_cli_oauth_status_error')} ${errorMessage}`, 'error');
// 3秒后重置UI让用户能够重新开始
setTimeout(() => {
this.resetGeminiCliOAuthUI();
}, 3000);
} else if (status === 'wait') {
// 继续等待
if (statusElement) {
statusElement.textContent = i18n.t('auth_login.gemini_cli_oauth_status_waiting');
statusElement.style.color = 'var(--warning-text)';
}
}
} catch (error) {
clearInterval(pollInterval);
const statusElement = document.getElementById('gemini-cli-oauth-status');
if (statusElement) {
statusElement.textContent = `${i18n.t('auth_login.gemini_cli_oauth_polling_error')} ${error.message}`;
statusElement.style.color = 'var(--error-text)';
}
this.showNotification(`${i18n.t('auth_login.gemini_cli_oauth_polling_error')} ${error.message}`, 'error');
// 3秒后重置UI让用户能够重新开始
setTimeout(() => {
this.resetGeminiCliOAuthUI();
}, 3000);
}
}, 2000); // 每2秒轮询一次
// 设置超时5分钟后停止轮询
setTimeout(() => {
clearInterval(pollInterval);
}, 5 * 60 * 1000);
},
// 重置 Gemini CLI OAuth UI 到初始状态
resetGeminiCliOAuthUI() {
const urlInput = document.getElementById('gemini-cli-oauth-url');
const content = document.getElementById('gemini-cli-oauth-content');
const status = document.getElementById('gemini-cli-oauth-status');
// 清空并隐藏授权链接输入框
if (urlInput) {
urlInput.value = '';
}
// 隐藏整个授权链接内容区域
if (content) {
content.style.display = 'none';
}
// 清空状态显示
if (status) {
status.textContent = '';
status.style.color = '';
status.className = '';
}
},
// ===== Qwen OAuth 相关方法 =====
// 开始 Qwen OAuth 流程
async startQwenOAuth() {
try {
const response = await this.makeRequest('/qwen-auth-url?is_webui=1');
const authUrl = response.url;
const state = this.extractStateFromUrl(authUrl);
// 显示授权链接
const urlInput = document.getElementById('qwen-oauth-url');
const content = document.getElementById('qwen-oauth-content');
const status = document.getElementById('qwen-oauth-status');
if (urlInput) {
urlInput.value = authUrl;
}
if (content) {
content.style.display = 'block';
}
if (status) {
status.textContent = i18n.t('auth_login.qwen_oauth_status_waiting');
status.style.color = 'var(--warning-text)';
}
// 开始轮询认证状态
this.startQwenOAuthPolling(state);
} catch (error) {
this.showNotification(`${i18n.t('auth_login.qwen_oauth_start_error')} ${error.message}`, 'error');
}
},
// 打开 Qwen 授权链接
openQwenLink() {
const urlInput = document.getElementById('qwen-oauth-url');
if (urlInput && urlInput.value) {
window.open(urlInput.value, '_blank');
}
},
// 复制 Qwen 授权链接
async copyQwenLink() {
const urlInput = document.getElementById('qwen-oauth-url');
if (urlInput && urlInput.value) {
try {
await navigator.clipboard.writeText(urlInput.value);
this.showNotification(i18n.t('notification.link_copied'), 'success');
} catch (error) {
// 降级方案:使用传统的复制方法
urlInput.select();
document.execCommand('copy');
this.showNotification(i18n.t('notification.link_copied'), 'success');
}
}
},
// 开始轮询 Qwen OAuth 状态
startQwenOAuthPolling(state) {
if (!state) {
this.showNotification(i18n.t('auth_login.missing_state'), 'error');
return;
}
const pollInterval = setInterval(async () => {
try {
const response = await this.makeRequest(`/get-auth-status?state=${encodeURIComponent(state)}`);
const status = response.status;
const statusElement = document.getElementById('qwen-oauth-status');
if (status === 'ok') {
// 认证成功
clearInterval(pollInterval);
// 隐藏授权链接相关内容,恢复到初始状态
this.resetQwenOAuthUI();
// 显示成功通知
this.showNotification(i18n.t('auth_login.qwen_oauth_status_success'), 'success');
// 刷新认证文件列表
this.loadAuthFiles();
} else if (status === 'error') {
// 认证失败
clearInterval(pollInterval);
const errorMessage = response.error || 'Unknown error';
// 显示错误信息
if (statusElement) {
statusElement.textContent = `${i18n.t('auth_login.qwen_oauth_status_error')} ${errorMessage}`;
statusElement.style.color = 'var(--error-text)';
}
this.showNotification(`${i18n.t('auth_login.qwen_oauth_status_error')} ${errorMessage}`, 'error');
// 3秒后重置UI让用户能够重新开始
setTimeout(() => {
this.resetQwenOAuthUI();
}, 3000);
} else if (status === 'wait') {
// 继续等待
if (statusElement) {
statusElement.textContent = i18n.t('auth_login.qwen_oauth_status_waiting');
statusElement.style.color = 'var(--warning-text)';
}
}
} catch (error) {
clearInterval(pollInterval);
const statusElement = document.getElementById('qwen-oauth-status');
if (statusElement) {
statusElement.textContent = `${i18n.t('auth_login.qwen_oauth_polling_error')} ${error.message}`;
statusElement.style.color = 'var(--error-text)';
}
this.showNotification(`${i18n.t('auth_login.qwen_oauth_polling_error')} ${error.message}`, 'error');
// 3秒后重置UI让用户能够重新开始
setTimeout(() => {
this.resetQwenOAuthUI();
}, 3000);
}
}, 2000); // 每2秒轮询一次
// 设置超时5分钟后停止轮询
setTimeout(() => {
clearInterval(pollInterval);
}, 5 * 60 * 1000);
},
// 重置 Qwen OAuth UI 到初始状态
resetQwenOAuthUI() {
const urlInput = document.getElementById('qwen-oauth-url');
const content = document.getElementById('qwen-oauth-content');
const status = document.getElementById('qwen-oauth-status');
// 清空并隐藏授权链接输入框
if (urlInput) {
urlInput.value = '';
}
// 隐藏整个授权链接内容区域
if (content) {
content.style.display = 'none';
}
// 清空状态显示
if (status) {
status.textContent = '';
status.style.color = '';
status.className = '';
}
},
// ===== iFlow OAuth 相关方法 =====
// 开始 iFlow OAuth 流程
async startIflowOAuth() {
try {
const response = await this.makeRequest('/iflow-auth-url?is_webui=1');
const authUrl = response.url;
const state = this.extractStateFromUrl(authUrl);
// 显示授权链接
const urlInput = document.getElementById('iflow-oauth-url');
const content = document.getElementById('iflow-oauth-content');
const status = document.getElementById('iflow-oauth-status');
if (urlInput) {
urlInput.value = authUrl;
}
if (content) {
content.style.display = 'block';
}
if (status) {
status.textContent = i18n.t('auth_login.iflow_oauth_status_waiting');
status.style.color = 'var(--warning-text)';
}
// 开始轮询认证状态
this.startIflowOAuthPolling(state);
} catch (error) {
this.showNotification(`${i18n.t('auth_login.iflow_oauth_start_error')} ${error.message}`, 'error');
}
},
// 打开 iFlow 授权链接
openIflowLink() {
const urlInput = document.getElementById('iflow-oauth-url');
if (urlInput && urlInput.value) {
window.open(urlInput.value, '_blank');
}
},
// 复制 iFlow 授权链接
async copyIflowLink() {
const urlInput = document.getElementById('iflow-oauth-url');
if (urlInput && urlInput.value) {
try {
await navigator.clipboard.writeText(urlInput.value);
this.showNotification(i18n.t('notification.link_copied'), 'success');
} catch (error) {
// 降级方案:使用传统的复制方法
urlInput.select();
document.execCommand('copy');
this.showNotification(i18n.t('notification.link_copied'), 'success');
}
}
},
// 开始轮询 iFlow OAuth 状态
startIflowOAuthPolling(state) {
if (!state) {
this.showNotification(i18n.t('auth_login.missing_state'), 'error');
return;
}
const pollInterval = setInterval(async () => {
try {
const response = await this.makeRequest(`/get-auth-status?state=${encodeURIComponent(state)}`);
const status = response.status;
const statusElement = document.getElementById('iflow-oauth-status');
if (status === 'ok') {
// 认证成功
clearInterval(pollInterval);
// 隐藏授权链接相关内容,恢复到初始状态
this.resetIflowOAuthUI();
// 显示成功通知
this.showNotification(i18n.t('auth_login.iflow_oauth_status_success'), 'success');
// 刷新认证文件列表
this.loadAuthFiles();
} else if (status === 'error') {
// 认证失败
clearInterval(pollInterval);
const errorMessage = response.error || 'Unknown error';
// 显示错误信息
if (statusElement) {
statusElement.textContent = `${i18n.t('auth_login.iflow_oauth_status_error')} ${errorMessage}`;
statusElement.style.color = 'var(--error-text)';
}
this.showNotification(`${i18n.t('auth_login.iflow_oauth_status_error')} ${errorMessage}`, 'error');
// 3秒后重置UI让用户能够重新开始
setTimeout(() => {
this.resetIflowOAuthUI();
}, 3000);
} else if (status === 'wait') {
// 继续等待
if (statusElement) {
statusElement.textContent = i18n.t('auth_login.iflow_oauth_status_waiting');
statusElement.style.color = 'var(--warning-text)';
}
}
} catch (error) {
clearInterval(pollInterval);
const statusElement = document.getElementById('iflow-oauth-status');
if (statusElement) {
statusElement.textContent = `${i18n.t('auth_login.iflow_oauth_polling_error')} ${error.message}`;
statusElement.style.color = 'var(--error-text)';
}
this.showNotification(`${i18n.t('auth_login.iflow_oauth_polling_error')} ${error.message}`, 'error');
// 3秒后重置UI让用户能够重新开始
setTimeout(() => {
this.resetIflowOAuthUI();
}, 3000);
}
}, 2000); // 每2秒轮询一次
// 设置超时5分钟后停止轮询
setTimeout(() => {
clearInterval(pollInterval);
}, 5 * 60 * 1000);
},
// 重置 iFlow OAuth UI 到初始状态
resetIflowOAuthUI() {
const urlInput = document.getElementById('iflow-oauth-url');
const content = document.getElementById('iflow-oauth-content');
const status = document.getElementById('iflow-oauth-status');
// 清空并隐藏授权链接输入框
if (urlInput) {
urlInput.value = '';
}
// 隐藏整个授权链接内容区域
if (content) {
content.style.display = 'none';
}
// 清空状态显示
if (status) {
status.textContent = '';
status.style.color = '';
status.className = '';
}
}
};

296
src/modules/settings.js Normal file
View File

@@ -0,0 +1,296 @@
// 设置与开关相关方法模块
// 注意:这些函数依赖于在 CLIProxyManager 实例上提供的 makeRequest/clearCache/showNotification/errorHandler 等基础能力
export async function updateDebug(enabled) {
const previousValue = !enabled;
try {
await this.makeRequest('/debug', {
method: 'PUT',
body: JSON.stringify({ value: enabled })
});
this.clearCache('debug'); // 仅清除 debug 配置段的缓存
this.showNotification(i18n.t('notification.debug_updated'), 'success');
} catch (error) {
this.errorHandler.handleUpdateError(
error,
i18n.t('settings.debug_mode') || '调试模式',
() => document.getElementById('debug-toggle').checked = previousValue
);
}
}
export async function updateProxyUrl() {
const proxyUrl = document.getElementById('proxy-url').value.trim();
const previousValue = document.getElementById('proxy-url').getAttribute('data-previous-value') || '';
try {
await this.makeRequest('/proxy-url', {
method: 'PUT',
body: JSON.stringify({ value: proxyUrl })
});
this.clearCache('proxy-url'); // 仅清除 proxy-url 配置段的缓存
document.getElementById('proxy-url').setAttribute('data-previous-value', proxyUrl);
this.showNotification(i18n.t('notification.proxy_updated'), 'success');
} catch (error) {
this.errorHandler.handleUpdateError(
error,
i18n.t('settings.proxy_url') || '代理设置',
() => document.getElementById('proxy-url').value = previousValue
);
}
}
export async function clearProxyUrl() {
const previousValue = document.getElementById('proxy-url').value;
try {
await this.makeRequest('/proxy-url', { method: 'DELETE' });
document.getElementById('proxy-url').value = '';
document.getElementById('proxy-url').setAttribute('data-previous-value', '');
this.clearCache('proxy-url'); // 仅清除 proxy-url 配置段的缓存
this.showNotification(i18n.t('notification.proxy_cleared'), 'success');
} catch (error) {
this.errorHandler.handleUpdateError(
error,
i18n.t('settings.proxy_url') || '代理设置',
() => document.getElementById('proxy-url').value = previousValue
);
}
}
export async function updateRequestRetry() {
const retryInput = document.getElementById('request-retry');
const retryCount = parseInt(retryInput.value);
const previousValue = retryInput.getAttribute('data-previous-value') || '0';
try {
await this.makeRequest('/request-retry', {
method: 'PUT',
body: JSON.stringify({ value: retryCount })
});
this.clearCache('request-retry'); // 仅清除 request-retry 配置段的缓存
retryInput.setAttribute('data-previous-value', retryCount.toString());
this.showNotification(i18n.t('notification.retry_updated'), 'success');
} catch (error) {
this.errorHandler.handleUpdateError(
error,
i18n.t('settings.request_retry') || '重试设置',
() => retryInput.value = previousValue
);
}
}
export async function loadDebugSettings() {
try {
const debugValue = await this.getConfig('debug'); // 仅获取 debug 配置段
if (debugValue !== undefined) {
document.getElementById('debug-toggle').checked = debugValue;
}
} catch (error) {
this.errorHandler.handleLoadError(error, i18n.t('settings.debug_mode') || '调试设置');
}
}
export async function loadProxySettings() {
try {
const proxyUrl = await this.getConfig('proxy-url'); // 仅获取 proxy-url 配置段
const proxyInput = document.getElementById('proxy-url');
if (proxyUrl !== undefined) {
proxyInput.value = proxyUrl || '';
proxyInput.setAttribute('data-previous-value', proxyUrl || '');
}
} catch (error) {
this.errorHandler.handleLoadError(error, i18n.t('settings.proxy_settings') || '代理设置');
}
}
export async function loadRetrySettings() {
try {
const config = await this.getConfig();
if (config['request-retry'] !== undefined) {
document.getElementById('request-retry').value = config['request-retry'];
}
} catch (error) {
console.error('加载重试设置失败:', error);
}
}
export async function loadQuotaSettings() {
try {
const config = await this.getConfig();
if (config['quota-exceeded']) {
if (config['quota-exceeded']['switch-project'] !== undefined) {
document.getElementById('switch-project-toggle').checked = config['quota-exceeded']['switch-project'];
}
if (config['quota-exceeded']['switch-preview-model'] !== undefined) {
document.getElementById('switch-preview-model-toggle').checked = config['quota-exceeded']['switch-preview-model'];
}
}
} catch (error) {
console.error('加载配额设置失败:', error);
}
}
export async function loadUsageStatisticsSettings() {
try {
const config = await this.getConfig();
if (config['usage-statistics-enabled'] !== undefined) {
const usageToggle = document.getElementById('usage-statistics-enabled-toggle');
if (usageToggle) {
usageToggle.checked = config['usage-statistics-enabled'];
}
}
} catch (error) {
console.error('加载使用统计设置失败:', error);
}
}
export async function loadRequestLogSetting() {
try {
const config = await this.getConfig();
if (config['request-log'] !== undefined) {
const requestLogToggle = document.getElementById('request-log-toggle');
if (requestLogToggle) {
requestLogToggle.checked = config['request-log'];
}
}
} catch (error) {
console.error('加载请求日志设置失败:', error);
}
}
export async function loadWsAuthSetting() {
try {
const config = await this.getConfig();
if (config['ws-auth'] !== undefined) {
const wsAuthToggle = document.getElementById('ws-auth-toggle');
if (wsAuthToggle) {
wsAuthToggle.checked = config['ws-auth'];
}
}
} catch (error) {
console.error('加载 WebSocket 鉴权设置失败:', error);
}
}
export async function updateUsageStatisticsEnabled(enabled) {
try {
await this.makeRequest('/usage-statistics-enabled', {
method: 'PUT',
body: JSON.stringify({ value: enabled })
});
this.clearCache();
this.showNotification(i18n.t('notification.usage_statistics_updated'), 'success');
} catch (error) {
this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error');
const usageToggle = document.getElementById('usage-statistics-enabled-toggle');
if (usageToggle) {
usageToggle.checked = !enabled;
}
}
}
export async function updateRequestLog(enabled) {
try {
await this.makeRequest('/request-log', {
method: 'PUT',
body: JSON.stringify({ value: enabled })
});
this.clearCache();
this.showNotification(i18n.t('notification.request_log_updated'), 'success');
} catch (error) {
this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error');
const requestLogToggle = document.getElementById('request-log-toggle');
if (requestLogToggle) {
requestLogToggle.checked = !enabled;
}
}
}
export async function updateWsAuth(enabled) {
try {
await this.makeRequest('/ws-auth', {
method: 'PUT',
body: JSON.stringify({ value: enabled })
});
this.clearCache();
this.showNotification(i18n.t('notification.ws_auth_updated'), 'success');
} catch (error) {
this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error');
const wsAuthToggle = document.getElementById('ws-auth-toggle');
if (wsAuthToggle) {
wsAuthToggle.checked = !enabled;
}
}
}
export async function updateLoggingToFile(enabled) {
try {
await this.makeRequest('/logging-to-file', {
method: 'PUT',
body: JSON.stringify({ value: enabled })
});
this.clearCache();
this.showNotification(i18n.t('notification.logging_to_file_updated'), 'success');
// 显示或隐藏日志查看栏目
this.toggleLogsNavItem(enabled);
// 如果启用了日志记录,自动刷新日志
if (enabled) {
setTimeout(() => this.refreshLogs(), 500);
}
} catch (error) {
this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error');
const loggingToggle = document.getElementById('logging-to-file-toggle');
if (loggingToggle) {
loggingToggle.checked = !enabled;
}
}
}
export async function updateSwitchProject(enabled) {
try {
await this.makeRequest('/quota-exceeded/switch-project', {
method: 'PUT',
body: JSON.stringify({ value: enabled })
});
this.clearCache(); // 清除缓存
this.showNotification(i18n.t('notification.quota_switch_project_updated'), 'success');
} catch (error) {
this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error');
document.getElementById('switch-project-toggle').checked = !enabled;
}
}
export async function updateSwitchPreviewModel(enabled) {
try {
await this.makeRequest('/quota-exceeded/switch-preview-model', {
method: 'PUT',
body: JSON.stringify({ value: enabled })
});
this.clearCache(); // 清除缓存
this.showNotification(i18n.t('notification.quota_switch_preview_updated'), 'success');
} catch (error) {
this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error');
document.getElementById('switch-preview-model-toggle').checked = !enabled;
}
}
export const settingsModule = {
updateDebug,
updateProxyUrl,
clearProxyUrl,
updateRequestRetry,
loadDebugSettings,
loadProxySettings,
loadRetrySettings,
loadQuotaSettings,
loadUsageStatisticsSettings,
loadRequestLogSetting,
loadWsAuthSetting,
updateUsageStatisticsEnabled,
updateRequestLog,
updateWsAuth,
updateLoggingToFile,
updateSwitchProject,
updateSwitchPreviewModel
};

73
src/modules/theme.js Normal file
View File

@@ -0,0 +1,73 @@
export const themeModule = {
initializeTheme() {
const savedTheme = localStorage.getItem('preferredTheme');
if (savedTheme && ['light', 'dark'].includes(savedTheme)) {
this.currentTheme = savedTheme;
} else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
this.currentTheme = 'dark';
} else {
this.currentTheme = 'light';
}
this.applyTheme(this.currentTheme);
this.updateThemeButtons();
if (window.matchMedia) {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
if (!localStorage.getItem('preferredTheme')) {
this.currentTheme = e.matches ? 'dark' : 'light';
this.applyTheme(this.currentTheme);
this.updateThemeButtons();
}
});
}
},
applyTheme(theme) {
if (theme === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
} else {
document.documentElement.removeAttribute('data-theme');
}
this.currentTheme = theme;
},
toggleTheme() {
const newTheme = this.currentTheme === 'light' ? 'dark' : 'light';
this.applyTheme(newTheme);
this.updateThemeButtons();
localStorage.setItem('preferredTheme', newTheme);
},
updateThemeButtons() {
const loginThemeBtn = document.getElementById('theme-toggle');
const mainThemeBtn = document.getElementById('theme-toggle-main');
const updateButton = (btn) => {
if (!btn) return;
const icon = btn.querySelector('i');
if (this.currentTheme === 'dark') {
icon.className = 'fas fa-sun';
btn.title = i18n.t('theme.switch_to_light');
} else {
icon.className = 'fas fa-moon';
btn.title = i18n.t('theme.switch_to_dark');
}
};
updateButton(loginThemeBtn);
updateButton(mainThemeBtn);
},
setupThemeSwitcher() {
const loginToggle = document.getElementById('theme-toggle');
const mainToggle = document.getElementById('theme-toggle-main');
if (loginToggle) {
loginToggle.addEventListener('click', () => this.toggleTheme());
}
if (mainToggle) {
mainToggle.addEventListener('click', () => this.toggleTheme());
}
}
};

745
src/modules/usage.js Normal file
View File

@@ -0,0 +1,745 @@
// 获取API密钥的统计信息
export async function getKeyStats(usageData = null) {
try {
let usage = usageData;
if (!usage) {
const response = await this.makeRequest('/usage');
usage = response?.usage || null;
}
if (!usage) {
return { bySource: {}, byAuthIndex: {} };
}
const sourceStats = {};
const authIndexStats = {};
const ensureBucket = (bucket, key) => {
if (!bucket[key]) {
bucket[key] = { success: 0, failure: 0 };
}
return bucket[key];
};
const normalizeAuthIndex = (value) => {
if (typeof value === 'number' && Number.isFinite(value)) {
return value.toString();
}
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed ? trimmed : null;
}
return null;
};
const apis = usage.apis || {};
Object.values(apis).forEach(apiEntry => {
const models = apiEntry.models || {};
Object.values(models).forEach(modelEntry => {
const details = modelEntry.details || [];
details.forEach(detail => {
const source = detail.source;
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
};
} catch (error) {
console.error('获取统计信息失败:', error);
return { bySource: {}, byAuthIndex: {} };
}
}
// 加载使用统计
export async function loadUsageStats(usageData = null) {
try {
let usage = usageData;
// 如果没有传入usage数据则调用API获取
if (!usage) {
const response = await this.makeRequest('/usage');
usage = response?.usage || null;
}
this.currentUsageData = usage;
if (!usage) {
throw new Error('usage payload missing');
}
// 更新概览卡片
this.updateUsageOverview(usage);
this.updateChartLineSelectors(usage);
// 读取当前图表周期
const requestsHourActive = document.getElementById('requests-hour-btn')?.classList.contains('active');
const tokensHourActive = document.getElementById('tokens-hour-btn')?.classList.contains('active');
const requestsPeriod = requestsHourActive ? 'hour' : 'day';
const tokensPeriod = tokensHourActive ? 'hour' : 'day';
// 初始化图表(使用当前周期)
this.initializeRequestsChart(requestsPeriod);
this.initializeTokensChart(tokensPeriod);
// 更新API详细统计表格
this.updateApiStatsTable(usage);
} catch (error) {
console.error('加载使用统计失败:', error);
this.currentUsageData = null;
this.updateChartLineSelectors(null);
// 清空概览数据
['total-requests', 'success-requests', 'failed-requests', 'total-tokens'].forEach(id => {
const el = document.getElementById(id);
if (el) el.textContent = '-';
});
// 清空图表
if (this.requestsChart) {
this.requestsChart.destroy();
this.requestsChart = null;
}
if (this.tokensChart) {
this.tokensChart.destroy();
this.tokensChart = null;
}
const tableElement = document.getElementById('api-stats-table');
if (tableElement) {
tableElement.innerHTML = `<div class="no-data-message">${i18n.t('usage_stats.loading_error')}: ${error.message}</div>`;
}
}
}
// 更新使用统计概览
export function updateUsageOverview(data) {
const safeData = data || {};
document.getElementById('total-requests').textContent = safeData.total_requests ?? 0;
document.getElementById('success-requests').textContent = safeData.success_count ?? 0;
document.getElementById('failed-requests').textContent = safeData.failure_count ?? 0;
document.getElementById('total-tokens').textContent = safeData.total_tokens ?? 0;
}
export function getModelNamesFromUsage(usage) {
if (!usage) {
return [];
}
const apis = usage.apis || {};
const names = new Set();
Object.values(apis).forEach(apiEntry => {
const models = apiEntry.models || {};
Object.keys(models).forEach(modelName => {
if (modelName) {
names.add(modelName);
}
});
});
return Array.from(names).sort((a, b) => a.localeCompare(b));
}
export function updateChartLineSelectors(usage) {
const modelNames = this.getModelNamesFromUsage(usage);
const selectors = this.chartLineSelectIds
.map(id => document.getElementById(id))
.filter(Boolean);
if (!selectors.length) {
this.chartLineSelections = ['none', 'none', 'none'];
return;
}
const optionsFragment = () => {
const fragment = document.createDocumentFragment();
const hiddenOption = document.createElement('option');
hiddenOption.value = 'none';
hiddenOption.textContent = i18n.t('usage_stats.chart_line_hidden');
fragment.appendChild(hiddenOption);
modelNames.forEach(name => {
const option = document.createElement('option');
option.value = name;
option.textContent = name;
fragment.appendChild(option);
});
return fragment;
};
const hasModels = modelNames.length > 0;
selectors.forEach(select => {
select.innerHTML = '';
select.appendChild(optionsFragment());
select.disabled = !hasModels;
});
if (!hasModels) {
this.chartLineSelections = ['none', 'none', 'none'];
selectors.forEach(select => {
select.value = 'none';
});
return;
}
const nextSelections = Array.isArray(this.chartLineSelections)
? [...this.chartLineSelections]
: ['none', 'none', 'none'];
const validNames = new Set(modelNames);
let hasActiveSelection = false;
for (let i = 0; i < nextSelections.length; i++) {
const selection = nextSelections[i];
if (selection && selection !== 'none' && !validNames.has(selection)) {
nextSelections[i] = 'none';
}
if (nextSelections[i] !== 'none') {
hasActiveSelection = true;
}
}
if (!hasActiveSelection) {
modelNames.slice(0, nextSelections.length).forEach((name, index) => {
nextSelections[index] = name;
});
}
this.chartLineSelections = nextSelections;
selectors.forEach((select, index) => {
const value = this.chartLineSelections[index] || 'none';
select.value = value;
});
}
export function handleChartLineSelectionChange(index, value) {
if (!Array.isArray(this.chartLineSelections)) {
this.chartLineSelections = ['none', 'none', 'none'];
}
if (index < 0 || index >= this.chartLineSelections.length) {
return;
}
const normalized = value || 'none';
if (this.chartLineSelections[index] === normalized) {
return;
}
this.chartLineSelections[index] = normalized;
this.refreshChartsForSelections();
}
export function refreshChartsForSelections() {
if (!this.currentUsageData) {
return;
}
const requestsHourActive = document.getElementById('requests-hour-btn')?.classList.contains('active');
const tokensHourActive = document.getElementById('tokens-hour-btn')?.classList.contains('active');
const requestsPeriod = requestsHourActive ? 'hour' : 'day';
const tokensPeriod = tokensHourActive ? 'hour' : 'day';
if (this.requestsChart) {
this.requestsChart.data = this.getRequestsChartData(requestsPeriod);
this.requestsChart.update();
} else {
this.initializeRequestsChart(requestsPeriod);
}
if (this.tokensChart) {
this.tokensChart.data = this.getTokensChartData(tokensPeriod);
this.tokensChart.update();
} else {
this.initializeTokensChart(tokensPeriod);
}
}
export function getActiveChartLineSelections() {
if (!Array.isArray(this.chartLineSelections)) {
this.chartLineSelections = ['none', 'none', 'none'];
}
return this.chartLineSelections
.map((value, index) => ({ model: value, index }))
.filter(item => item.model && item.model !== 'none');
}
// 收集所有请求明细,供图表等复用
export function collectUsageDetailsFromUsage(usage) {
if (!usage) {
return [];
}
const apis = usage.apis || {};
const details = [];
Object.values(apis).forEach(apiEntry => {
const models = apiEntry.models || {};
Object.entries(models).forEach(([modelName, modelEntry]) => {
const modelDetails = Array.isArray(modelEntry.details) ? modelEntry.details : [];
modelDetails.forEach(detail => {
if (detail && detail.timestamp) {
details.push({
...detail,
__modelName: modelName
});
}
});
});
});
return details;
}
export function collectUsageDetails() {
return this.collectUsageDetailsFromUsage(this.currentUsageData);
}
export function createHourlyBucketMeta() {
const hourMs = 60 * 60 * 1000;
const now = new Date();
const currentHour = new Date(now);
currentHour.setMinutes(0, 0, 0);
const earliestBucket = new Date(currentHour);
earliestBucket.setHours(earliestBucket.getHours() - 23);
const earliestTime = earliestBucket.getTime();
const labels = [];
for (let i = 0; i < 24; i++) {
const bucketStart = earliestTime + i * hourMs;
labels.push(this.formatHourLabel(new Date(bucketStart)));
}
return {
labels,
earliestTime,
bucketSize: hourMs,
lastBucketTime: earliestTime + (labels.length - 1) * hourMs
};
}
export function buildHourlySeriesByModel(metric = 'requests') {
const meta = this.createHourlyBucketMeta();
const details = this.collectUsageDetails();
const dataByModel = new Map();
let hasData = false;
if (!details.length) {
return { labels: meta.labels, dataByModel, hasData };
}
details.forEach(detail => {
const timestamp = Date.parse(detail.timestamp);
if (Number.isNaN(timestamp)) {
return;
}
const normalized = new Date(timestamp);
normalized.setMinutes(0, 0, 0);
const bucketStart = normalized.getTime();
if (bucketStart < meta.earliestTime || bucketStart > meta.lastBucketTime) {
return;
}
const bucketIndex = Math.floor((bucketStart - meta.earliestTime) / meta.bucketSize);
if (bucketIndex < 0 || bucketIndex >= meta.labels.length) {
return;
}
const modelName = detail.__modelName || 'Unknown';
if (!dataByModel.has(modelName)) {
dataByModel.set(modelName, new Array(meta.labels.length).fill(0));
}
const bucketValues = dataByModel.get(modelName);
if (metric === 'tokens') {
bucketValues[bucketIndex] += this.extractTotalTokens(detail);
} else {
bucketValues[bucketIndex] += 1;
}
hasData = true;
});
return { labels: meta.labels, dataByModel, hasData };
}
export function buildDailySeriesByModel(metric = 'requests') {
const details = this.collectUsageDetails();
const valuesByModel = new Map();
const labelsSet = new Set();
let hasData = false;
if (!details.length) {
return { labels: [], dataByModel: new Map(), hasData };
}
details.forEach(detail => {
const timestamp = Date.parse(detail.timestamp);
if (Number.isNaN(timestamp)) {
return;
}
const dayLabel = this.formatDayLabel(new Date(timestamp));
if (!dayLabel) {
return;
}
const modelName = detail.__modelName || 'Unknown';
if (!valuesByModel.has(modelName)) {
valuesByModel.set(modelName, new Map());
}
const modelDayMap = valuesByModel.get(modelName);
const increment = metric === 'tokens' ? this.extractTotalTokens(detail) : 1;
modelDayMap.set(dayLabel, (modelDayMap.get(dayLabel) || 0) + increment);
labelsSet.add(dayLabel);
hasData = true;
});
const labels = Array.from(labelsSet).sort();
const dataByModel = new Map();
valuesByModel.forEach((dayMap, modelName) => {
const series = labels.map(label => dayMap.get(label) || 0);
dataByModel.set(modelName, series);
});
return { labels, dataByModel, hasData };
}
export function buildChartDataForMetric(period = 'day', metric = 'requests') {
const baseSeries = period === 'hour'
? this.buildHourlySeriesByModel(metric)
: this.buildDailySeriesByModel(metric);
const labels = baseSeries?.labels || [];
const dataByModel = baseSeries?.dataByModel || new Map();
const activeSelections = this.getActiveChartLineSelections();
const datasets = activeSelections.map(selection => {
const values = dataByModel.get(selection.model) || new Array(labels.length).fill(0);
const style = this.chartLineStyles[selection.index] || this.chartLineStyles[0];
return {
label: selection.model,
data: values,
borderColor: style.borderColor,
backgroundColor: style.backgroundColor,
fill: false,
tension: 0.35,
pointBackgroundColor: style.borderColor,
pointBorderColor: '#ffffff',
pointBorderWidth: 2,
pointRadius: values.some(v => v > 0) ? 4 : 3
};
});
return { labels, datasets };
}
// 统一格式化小时标签
export function formatHourLabel(date) {
if (!(date instanceof Date)) {
return '';
}
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const day = date.getDate().toString().padStart(2, '0');
const hour = date.getHours().toString().padStart(2, '0');
return `${month}-${day} ${hour}:00`;
}
export function formatDayLabel(date) {
if (!(date instanceof Date)) {
return '';
}
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const day = date.getDate().toString().padStart(2, '0');
return `${year}-${month}-${day}`;
}
export function extractTotalTokens(detail) {
const tokens = detail?.tokens || {};
if (typeof tokens.total_tokens === 'number') {
return tokens.total_tokens;
}
const tokenKeys = ['input_tokens', 'output_tokens', 'reasoning_tokens', 'cached_tokens'];
return tokenKeys.reduce((sum, key) => {
const value = tokens[key];
return sum + (typeof value === 'number' ? value : 0);
}, 0);
}
// 初始化图表
export function initializeCharts() {
const requestsHourActive = document.getElementById('requests-hour-btn')?.classList.contains('active');
const tokensHourActive = document.getElementById('tokens-hour-btn')?.classList.contains('active');
this.initializeRequestsChart(requestsHourActive ? 'hour' : 'day');
this.initializeTokensChart(tokensHourActive ? 'hour' : 'day');
}
// 初始化请求趋势图表
export function initializeRequestsChart(period = 'day') {
const ctx = document.getElementById('requests-chart');
if (!ctx) return;
// 销毁现有图表
if (this.requestsChart) {
this.requestsChart.destroy();
}
const data = this.getRequestsChartData(period);
this.requestsChart = new Chart(ctx, {
type: 'line',
data: data,
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false
},
plugins: {
legend: {
display: true,
position: 'top',
align: 'start',
labels: {
usePointStyle: true
}
}
},
scales: {
x: {
title: {
display: true,
text: i18n.t(period === 'hour' ? 'usage_stats.by_hour' : 'usage_stats.by_day')
}
},
y: {
beginAtZero: true,
title: {
display: true,
text: i18n.t('usage_stats.requests_count')
}
}
},
elements: {
line: {
tension: 0.35,
borderWidth: 2
},
point: {
borderWidth: 2,
radius: 4
}
}
}
});
}
// 初始化Token使用趋势图表
export function initializeTokensChart(period = 'day') {
const ctx = document.getElementById('tokens-chart');
if (!ctx) return;
// 销毁现有图表
if (this.tokensChart) {
this.tokensChart.destroy();
}
const data = this.getTokensChartData(period);
this.tokensChart = new Chart(ctx, {
type: 'line',
data: data,
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false
},
plugins: {
legend: {
display: true,
position: 'top',
align: 'start',
labels: {
usePointStyle: true
}
}
},
scales: {
x: {
title: {
display: true,
text: i18n.t(period === 'hour' ? 'usage_stats.by_hour' : 'usage_stats.by_day')
}
},
y: {
beginAtZero: true,
title: {
display: true,
text: i18n.t('usage_stats.tokens_count')
}
}
},
elements: {
line: {
tension: 0.35,
borderWidth: 2
},
point: {
borderWidth: 2,
radius: 4
}
}
}
});
}
// 获取请求图表数据
export function getRequestsChartData(period) {
if (!this.currentUsageData) {
return { labels: [], datasets: [] };
}
return this.buildChartDataForMetric(period, 'requests');
}
// 获取Token图表数据
export function getTokensChartData(period) {
if (!this.currentUsageData) {
return { labels: [], datasets: [] };
}
return this.buildChartDataForMetric(period, 'tokens');
}
// 切换请求图表时间周期
export function switchRequestsPeriod(period) {
// 更新按钮状态
document.getElementById('requests-hour-btn').classList.toggle('active', period === 'hour');
document.getElementById('requests-day-btn').classList.toggle('active', period === 'day');
// 更新图表数据
if (this.requestsChart) {
const newData = this.getRequestsChartData(period);
this.requestsChart.data = newData;
this.requestsChart.options.scales.x.title.text = i18n.t(period === 'hour' ? 'usage_stats.by_hour' : 'usage_stats.by_day');
this.requestsChart.update();
}
}
// 切换Token图表时间周期
export function switchTokensPeriod(period) {
// 更新按钮状态
document.getElementById('tokens-hour-btn').classList.toggle('active', period === 'hour');
document.getElementById('tokens-day-btn').classList.toggle('active', period === 'day');
// 更新图表数据
if (this.tokensChart) {
const newData = this.getTokensChartData(period);
this.tokensChart.data = newData;
this.tokensChart.options.scales.x.title.text = i18n.t(period === 'hour' ? 'usage_stats.by_hour' : 'usage_stats.by_day');
this.tokensChart.update();
}
}
// 更新API详细统计表格
export function updateApiStatsTable(data) {
const container = document.getElementById('api-stats-table');
if (!container) return;
const apis = data.apis || {};
if (Object.keys(apis).length === 0) {
container.innerHTML = `<div class="no-data-message">${i18n.t('usage_stats.no_data')}</div>`;
return;
}
let tableHtml = `
<table class="stats-table">
<thead>
<tr>
<th>${i18n.t('usage_stats.api_endpoint')}</th>
<th>${i18n.t('usage_stats.requests_count')}</th>
<th>${i18n.t('usage_stats.tokens_count')}</th>
<th>${i18n.t('usage_stats.success_rate')}</th>
<th>${i18n.t('usage_stats.models')}</th>
</tr>
</thead>
<tbody>
`;
Object.entries(apis).forEach(([endpoint, apiData]) => {
const totalRequests = apiData.total_requests || 0;
const successCount = apiData.success_count ?? null;
const successRate = successCount !== null && totalRequests > 0
? Math.round((successCount / totalRequests) * 100)
: null;
// 构建模型详情
let modelsHtml = '';
if (apiData.models && Object.keys(apiData.models).length > 0) {
modelsHtml = '<div class="model-details">';
Object.entries(apiData.models).forEach(([modelName, modelData]) => {
const modelRequests = modelData.total_requests ?? 0;
const modelTokens = modelData.total_tokens ?? 0;
modelsHtml += `
<div class="model-item">
<span class="model-name">${modelName}</span>
<span>${modelRequests} 请求 / ${modelTokens} tokens</span>
</div>
`;
});
modelsHtml += '</div>';
}
tableHtml += `
<tr>
<td>${endpoint}</td>
<td>${totalRequests}</td>
<td>${apiData.total_tokens || 0}</td>
<td>${successRate !== null ? successRate + '%' : '-'}</td>
<td>${modelsHtml || '-'}</td>
</tr>
`;
});
tableHtml += '</tbody></table>';
container.innerHTML = tableHtml;
}
export const usageModule = {
getKeyStats,
loadUsageStats,
updateUsageOverview,
getModelNamesFromUsage,
updateChartLineSelectors,
handleChartLineSelectionChange,
refreshChartsForSelections,
getActiveChartLineSelections,
collectUsageDetailsFromUsage,
collectUsageDetails,
createHourlyBucketMeta,
buildHourlySeriesByModel,
buildDailySeriesByModel,
buildChartDataForMetric,
formatHourLabel,
formatDayLabel,
extractTotalTokens,
initializeCharts,
initializeRequestsChart,
initializeTokensChart,
getRequestsChartData,
getTokensChartData,
switchRequestsPeriod,
switchTokensPeriod,
updateApiStatsTable
};

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

@@ -0,0 +1,172 @@
/**
* 数组工具函数模块
* 提供数组处理、规范化、排序等功能
*/
/**
* 规范化 API 响应中的数组数据
* 兼容多种服务端返回格式
*
* @param {*} data - API 响应数据
* @param {string} [key] - 数组字段的键名
* @returns {Array} 规范化后的数组
*
* @example
* // 直接返回数组
* normalizeArrayResponse([1, 2, 3])
* // 返回: [1, 2, 3]
*
* // 从对象中提取数组
* normalizeArrayResponse({ 'api-keys': ['key1', 'key2'] }, 'api-keys')
* // 返回: ['key1', 'key2']
*
* // 从 items 字段提取
* normalizeArrayResponse({ items: ['a', 'b'] })
* // 返回: ['a', 'b']
*/
export function normalizeArrayResponse(data, key) {
// 如果本身就是数组,直接返回
if (Array.isArray(data)) {
return data;
}
// 如果指定了 key尝试从对象中提取
if (key && data && Array.isArray(data[key])) {
return data[key];
}
// 尝试从 items 字段提取(通用分页格式)
if (data && Array.isArray(data.items)) {
return data.items;
}
// 默认返回空数组
return [];
}
/**
* 数组去重
* @param {Array} arr - 原数组
* @param {Function} [keyFn] - 提取键的函数,用于对象数组去重
* @returns {Array} 去重后的数组
*
* @example
* uniqueArray([1, 2, 2, 3])
* // 返回: [1, 2, 3]
*
* uniqueArray([{id: 1}, {id: 2}, {id: 1}], item => item.id)
* // 返回: [{id: 1}, {id: 2}]
*/
export function uniqueArray(arr, keyFn) {
if (!Array.isArray(arr)) return [];
if (keyFn) {
const seen = new Set();
return arr.filter(item => {
const key = keyFn(item);
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
return [...new Set(arr)];
}
/**
* 数组分组
* @param {Array} arr - 原数组
* @param {Function} keyFn - 提取分组键的函数
* @returns {Object} 分组后的对象
*
* @example
* groupBy([{type: 'a', val: 1}, {type: 'b', val: 2}, {type: 'a', val: 3}], item => item.type)
* // 返回: { a: [{type: 'a', val: 1}, {type: 'a', val: 3}], b: [{type: 'b', val: 2}] }
*/
export function groupBy(arr, keyFn) {
if (!Array.isArray(arr)) return {};
return arr.reduce((groups, item) => {
const key = keyFn(item);
if (!groups[key]) {
groups[key] = [];
}
groups[key].push(item);
return groups;
}, {});
}
/**
* 数组分块
* @param {Array} arr - 原数组
* @param {number} size - 每块大小
* @returns {Array<Array>} 分块后的二维数组
*
* @example
* chunk([1, 2, 3, 4, 5], 2)
* // 返回: [[1, 2], [3, 4], [5]]
*/
export function chunk(arr, size) {
if (!Array.isArray(arr) || size < 1) return [];
const result = [];
for (let i = 0; i < arr.length; i += size) {
result.push(arr.slice(i, i + size));
}
return result;
}
/**
* 数组排序(不改变原数组)
* @param {Array} arr - 原数组
* @param {Function} compareFn - 比较函数
* @returns {Array} 排序后的新数组
*/
export function sortArray(arr, compareFn) {
if (!Array.isArray(arr)) return [];
return [...arr].sort(compareFn);
}
/**
* 按字段排序对象数组
* @param {Array} arr - 对象数组
* @param {string} key - 排序字段
* @param {string} order - 排序顺序 'asc' 或 'desc'
* @returns {Array} 排序后的新数组
*
* @example
* sortByKey([{age: 25}, {age: 20}, {age: 30}], 'age', 'asc')
* // 返回: [{age: 20}, {age: 25}, {age: 30}]
*/
export function sortByKey(arr, key, order = 'asc') {
if (!Array.isArray(arr)) return [];
return [...arr].sort((a, b) => {
const aVal = a[key];
const bVal = b[key];
if (aVal < bVal) return order === 'asc' ? -1 : 1;
if (aVal > bVal) return order === 'asc' ? 1 : -1;
return 0;
});
}
/**
* 安全地获取数组元素
* @param {Array} arr - 数组
* @param {number} index - 索引
* @param {*} defaultValue - 默认值
* @returns {*} 数组元素或默认值
*/
export function safeGet(arr, index, defaultValue = undefined) {
if (!Array.isArray(arr) || index < 0 || index >= arr.length) {
return defaultValue;
}
return arr[index];
}
/**
* 检查数组是否为空
* @param {*} arr - 待检查的值
* @returns {boolean} 是否为空数组
*/
export function isEmptyArray(arr) {
return !Array.isArray(arr) || arr.length === 0;
}

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

@@ -0,0 +1,274 @@
/**
* 常量配置文件
* 集中管理应用中的所有常量,避免魔法数字和硬编码字符串
*/
// ============================================================
// 时间相关常量(毫秒)
// ============================================================
/**
* 配置缓存过期时间30秒
* 用于减少服务器压力,避免频繁请求配置数据
*/
export const CACHE_EXPIRY_MS = 30 * 1000;
/**
* 通知显示持续时间3秒
* 成功/错误/信息提示框的自动消失时间
*/
export const NOTIFICATION_DURATION_MS = 3 * 1000;
/**
* 状态更新定时器间隔1秒
* 连接状态和系统信息的更新频率
*/
export const STATUS_UPDATE_INTERVAL_MS = 1 * 1000;
/**
* 日志刷新延迟500毫秒
* 日志自动刷新的去抖延迟
*/
export const LOG_REFRESH_DELAY_MS = 500;
/**
* OAuth 状态轮询间隔2秒
* 检查 OAuth 认证完成状态的轮询频率
*/
export const OAUTH_POLL_INTERVAL_MS = 2 * 1000;
/**
* OAuth 最大轮询时间5分钟
* 超过此时间后停止轮询,认为授权超时
*/
export const OAUTH_MAX_POLL_DURATION_MS = 5 * 60 * 1000;
// ============================================================
// 数据限制常量
// ============================================================
/**
* 最大日志显示行数
* 限制内存占用,避免大量日志导致页面卡顿
*/
export const MAX_LOG_LINES = 2000;
/**
* 认证文件列表默认每页显示数量
*/
export const DEFAULT_AUTH_FILES_PAGE_SIZE = 9;
/**
* 认证文件每页最小显示数量
*/
export const MIN_AUTH_FILES_PAGE_SIZE = 3;
/**
* 认证文件每页最大显示数量
*/
export const MAX_AUTH_FILES_PAGE_SIZE = 60;
/**
* 使用统计图表最大数据点数
* 超过此数量将进行聚合,提高渲染性能
*/
export const MAX_CHART_DATA_POINTS = 100;
// ============================================================
// 网络相关常量
// ============================================================
/**
* 默认 API 服务器端口
*/
export const DEFAULT_API_PORT = 8317;
/**
* 默认 API 基础路径
*/
export const DEFAULT_API_BASE = `http://localhost:${DEFAULT_API_PORT}`;
/**
* 管理 API 路径前缀
*/
export const MANAGEMENT_API_PREFIX = '/v0/management';
/**
* 请求超时时间30秒
*/
export const REQUEST_TIMEOUT_MS = 30 * 1000;
// ============================================================
// OAuth 相关常量
// ============================================================
/**
* OAuth 卡片元素 ID 列表
* 用于根据主机环境隐藏/显示不同的 OAuth 选项
*/
export const OAUTH_CARD_IDS = [
'codex-oauth-card',
'anthropic-oauth-card',
'gemini-cli-oauth-card',
'qwen-oauth-card',
'iflow-oauth-card'
];
/**
* OAuth 提供商名称映射
*/
export const OAUTH_PROVIDERS = {
CODEX: 'codex',
ANTHROPIC: 'anthropic',
GEMINI_CLI: 'gemini-cli',
QWEN: 'qwen',
IFLOW: 'iflow'
};
// ============================================================
// 本地存储键名
// ============================================================
/**
* 本地存储键名前缀
*/
export const STORAGE_PREFIX = 'cliProxyApi_';
/**
* 存储 API 基础地址的键名
*/
export const STORAGE_KEY_API_BASE = `${STORAGE_PREFIX}apiBase`;
/**
* 存储管理密钥的键名
*/
export const STORAGE_KEY_MANAGEMENT_KEY = `${STORAGE_PREFIX}managementKey`;
/**
* 存储主题偏好的键名
*/
export const STORAGE_KEY_THEME = `${STORAGE_PREFIX}theme`;
/**
* 存储语言偏好的键名
*/
export const STORAGE_KEY_LANGUAGE = `${STORAGE_PREFIX}language`;
/**
* 存储认证文件页大小的键名
*/
export const STORAGE_KEY_AUTH_FILES_PAGE_SIZE = `${STORAGE_PREFIX}authFilesPageSize`;
// ============================================================
// UI 相关常量
// ============================================================
/**
* 主题选项
*/
export const THEMES = {
LIGHT: 'light',
DARK: 'dark'
};
/**
* 支持的语言
*/
export const LANGUAGES = {
ZH_CN: 'zh-CN',
EN_US: 'en-US'
};
/**
* 通知类型
*/
export const NOTIFICATION_TYPES = {
SUCCESS: 'success',
ERROR: 'error',
INFO: 'info',
WARNING: 'warning'
};
/**
* 模态框尺寸
*/
export const MODAL_SIZES = {
SMALL: 'small',
MEDIUM: 'medium',
LARGE: 'large'
};
// ============================================================
// 正则表达式常量
// ============================================================
/**
* URL 验证正则
*/
export const URL_PATTERN = /^https?:\/\/.+/;
/**
* Email 验证正则
*/
export const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
/**
* 端口号验证正则1-65535
*/
export const PORT_PATTERN = /^([1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])$/;
// ============================================================
// 文件类型常量
// ============================================================
/**
* 支持的认证文件类型
*/
export const AUTH_FILE_TYPES = {
JSON: 'application/json',
YAML: 'application/x-yaml'
};
/**
* 认证文件最大大小10MB
*/
export const MAX_AUTH_FILE_SIZE = 10 * 1024 * 1024;
// ============================================================
// API 端点常量
// ============================================================
/**
* 常用 API 端点路径
*/
export const API_ENDPOINTS = {
CONFIG: '/config',
DEBUG: '/debug',
API_KEYS: '/api-keys',
PROVIDERS: '/providers',
AUTH_FILES: '/auth-files',
LOGS: '/logs',
USAGE_STATS: '/usage-stats',
CONNECTION: '/connection',
CODEX_API_KEY: '/codex-api-key',
ANTHROPIC_API_KEY: '/anthropic-api-key',
GEMINI_API_KEY: '/gemini-api-key',
OPENAI_API_KEY: '/openai-api-key'
};
// ============================================================
// 错误消息常量
// ============================================================
/**
* 通用错误消息
*/
export const ERROR_MESSAGES = {
NETWORK_ERROR: '网络连接失败,请检查服务器状态',
TIMEOUT: '请求超时,请稍后重试',
UNAUTHORIZED: '未授权,请检查管理密钥',
NOT_FOUND: '资源不存在',
SERVER_ERROR: '服务器错误,请联系管理员',
INVALID_INPUT: '输入数据无效',
OPERATION_FAILED: '操作失败,请稍后重试'
};

279
src/utils/dom.js Normal file
View File

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

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

@@ -0,0 +1,62 @@
/**
* HTML 工具函数模块
* 提供 HTML 字符串处理、XSS 防护等功能
*/
/**
* HTML 转义,防止 XSS 攻击
* @param {*} value - 需要转义的值
* @returns {string} 转义后的字符串
*
* @example
* escapeHtml('<script>alert("xss")</script>')
* // 返回: '&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;'
*/
export function escapeHtml(value) {
if (value === null || value === undefined) return '';
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* HTML 反转义
* @param {string} html - 需要反转义的 HTML 字符串
* @returns {string} 反转义后的字符串
*/
export function unescapeHtml(html) {
if (!html) return '';
const textarea = document.createElement('textarea');
textarea.innerHTML = html;
return textarea.value;
}
/**
* 去除 HTML 标签,只保留文本内容
* @param {string} html - HTML 字符串
* @returns {string} 纯文本内容
*
* @example
* stripHtmlTags('<p>Hello <strong>World</strong></p>')
* // 返回: 'Hello World'
*/
export function stripHtmlTags(html) {
if (!html) return '';
const div = document.createElement('div');
div.innerHTML = html;
return div.textContent || div.innerText || '';
}
/**
* 安全地设置元素的 HTML 内容
* @param {HTMLElement} element - 目标元素
* @param {string} html - HTML 内容
* @param {boolean} escape - 是否转义(默认 true
*/
export function setSafeHtml(element, html, escape = true) {
if (!element) return;
element.innerHTML = escape ? escapeHtml(html) : html;
}

128
src/utils/secure-storage.js Normal file
View File

@@ -0,0 +1,128 @@
// 简单的浏览器端加密存储封装
// 仅用于避免本地缓存中明文暴露敏感值,无法替代服务端安全控制
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 });
}
});
}
};

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

@@ -0,0 +1,116 @@
/**
* 字符串工具函数模块
* 提供字符串处理、格式化、掩码等功能
*/
/**
* 遮蔽 API 密钥显示,保护敏感信息
* @param {*} key - API 密钥
* @returns {string} 遮蔽后的密钥字符串
*
* @example
* maskApiKey('sk-1234567890abcdef')
* // 返回: 'sk-1...cdef'
*/
export function maskApiKey(key) {
if (key === null || key === undefined) {
return '';
}
const normalizedKey = typeof key === 'string' ? key : String(key);
if (normalizedKey.length > 8) {
return normalizedKey.substring(0, 4) + '...' + normalizedKey.substring(normalizedKey.length - 4);
} else if (normalizedKey.length > 4) {
return normalizedKey.substring(0, 2) + '...' + normalizedKey.substring(normalizedKey.length - 2);
} else if (normalizedKey.length > 2) {
return normalizedKey.substring(0, 1) + '...' + normalizedKey.substring(normalizedKey.length - 1);
}
return normalizedKey;
}
/**
* 截断字符串到指定长度,超出部分用省略号代替
* @param {string} str - 原字符串
* @param {number} maxLength - 最大长度
* @param {string} suffix - 后缀(默认 '...'
* @returns {string} 截断后的字符串
*
* @example
* truncateString('This is a very long string', 10)
* // 返回: 'This is...'
*/
export function truncateString(str, maxLength, suffix = '...') {
if (!str || str.length <= maxLength) return str || '';
return str.substring(0, maxLength - suffix.length) + suffix;
}
/**
* 格式化文件大小
* @param {number} bytes - 字节数
* @param {number} decimals - 小数位数(默认 2
* @returns {string} 格式化后的大小字符串
*
* @example
* formatFileSize(1536)
* // 返回: '1.50 KB'
*/
export function formatFileSize(bytes, decimals = 2) {
if (bytes === 0) return '0 Bytes';
if (!bytes || isNaN(bytes)) return '';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
/**
* 首字母大写
* @param {string} str - 原字符串
* @returns {string} 首字母大写后的字符串
*/
export function capitalize(str) {
if (!str) return '';
return str.charAt(0).toUpperCase() + str.slice(1);
}
/**
* 生成随机字符串
* @param {number} length - 字符串长度
* @param {string} charset - 字符集(默认字母数字)
* @returns {string} 随机字符串
*/
export function randomString(length = 8, charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789') {
let result = '';
for (let i = 0; i < length; i++) {
result += charset.charAt(Math.floor(Math.random() * charset.length));
}
return result;
}
/**
* 检查字符串是否为空或仅包含空白字符
* @param {string} str - 待检查的字符串
* @returns {boolean} 是否为空
*/
export function isBlank(str) {
return !str || /^\s*$/.test(str);
}
/**
* 将字符串转换为 kebab-case
* @param {string} str - 原字符串
* @returns {string} kebab-case 字符串
*
* @example
* toKebabCase('helloWorld')
* // 返回: 'hello-world'
*/
export function toKebabCase(str) {
if (!str) return '';
return str
.replace(/([a-z])([A-Z])/g, '$1-$2')
.replace(/[\s_]+/g, '-')
.toLowerCase();
}

View File

@@ -1248,6 +1248,21 @@ body {
transform: translateY(-1px);
}
.btn-small.btn-warning {
background: #ff9800;
color: #fff;
border-color: #ff9800;
cursor: default;
}
.btn-small.btn-warning:disabled,
.btn-small.btn-warning[disabled] {
background: #fb8c00;
border-color: #fb8c00;
color: #fff;
opacity: 1;
}
.btn-small i {
font-size: 13px;
}
@@ -1364,6 +1379,7 @@ textarea::placeholder {
display: flex;
flex-direction: column;
gap: 12px;
min-height: 520px;
}
#config-management .card {
@@ -1383,12 +1399,13 @@ textarea::placeholder {
flex: 1;
display: flex;
flex-direction: column;
min-height: 520px;
}
.yaml-editor {
width: 100%;
flex: 1;
min-height: 360px;
min-height: 520px;
border: 1px solid var(--border-primary);
border-radius: 6px;
padding: 12px 14px;
@@ -1402,6 +1419,7 @@ textarea::placeholder {
#config-management .CodeMirror {
flex: 1;
min-height: 520px;
height: 100%;
font-family: 'SFMono-Regular', 'Consolas', 'Liberation Mono', 'Courier New', monospace;
font-size: 14px;
@@ -1414,8 +1432,10 @@ textarea::placeholder {
#config-management .CodeMirror-scroll {
min-height: 0;
max-height: calc(100vh - 440px);
height: 100%;
max-height: none;
overflow-y: auto;
overscroll-behavior: contain;
}
#config-management .CodeMirror.cm-s-default {
@@ -1458,6 +1478,18 @@ textarea::placeholder {
opacity: 0.6;
}
@media (max-width: 768px) {
.yaml-editor-container,
#config-management .yaml-editor-container {
min-height: 360px;
}
.yaml-editor,
#config-management .CodeMirror {
min-height: 360px;
}
}
.editor-status {
font-size: 13px;
color: var(--text-quaternary);
@@ -1535,6 +1567,33 @@ input:checked+.slider:before {
gap: 15px;
}
.pagination-controls {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-top: 20px;
flex-wrap: wrap;
}
.pagination-btn {
gap: 6px;
min-width: 120px;
justify-content: center;
display: inline-flex;
align-items: center;
}
.pagination-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pagination-info {
font-size: 0.95rem;
color: var(--text-secondary);
}
/* 响应式中等屏幕2列 */
@media (max-width: 1400px) {
.file-grid {
@@ -1571,6 +1630,93 @@ input:checked+.slider:before {
white-space: nowrap;
}
/* 认证文件工具栏 */
.auth-file-toolbar {
display: flex;
flex-wrap: wrap;
gap: 18px;
align-items: flex-end;
margin-bottom: 18px;
}
.auth-file-search-group {
flex: 1;
min-width: 240px;
display: flex;
flex-direction: column;
gap: 6px;
}
.auth-file-search-group label,
.auth-file-page-size label {
font-weight: 600;
color: var(--text-secondary);
}
.auth-file-search {
display: flex;
align-items: center;
border: 1px solid var(--border-primary);
border-radius: 10px;
padding: 0 12px;
background: var(--bg-secondary);
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.03);
}
[data-theme="dark"] .auth-file-search {
background: var(--bg-tertiary);
box-shadow: inset 0 1px 1px rgba(255, 255, 255, 0.04);
}
.auth-file-search i {
color: var(--text-tertiary);
margin-right: 8px;
}
.auth-file-search input {
border: none;
background: transparent;
color: var(--text-primary);
width: 100%;
padding: 10px 0;
font-size: 0.95rem;
}
.auth-file-search input:focus {
outline: none;
}
.auth-file-page-size {
min-width: 180px;
display: flex;
flex-direction: column;
gap: 6px;
}
.page-size-input {
display: flex;
align-items: center;
gap: 8px;
border: 1px solid var(--border-primary);
border-radius: 10px;
padding: 8px 12px;
background: var(--bg-quaternary);
}
.page-size-input input {
width: 80px;
padding: 6px 8px;
border: 1px solid var(--border-secondary);
border-radius: 6px;
background: var(--bg-primary);
color: var(--text-primary);
}
.page-size-input span {
font-size: 0.9rem;
color: var(--text-tertiary);
}
/* 认证文件筛选按钮 */
.auth-file-filter {
display: flex;
@@ -1630,6 +1776,26 @@ input:checked+.slider:before {
color: #64b5f6;
}
.file-type-badge.gemini-cli {
background: #4f8de2;
color: #ffffff;
}
[data-theme="dark"] .file-type-badge.gemini-cli {
background: #4f8de2;
color: #eaf1fb;
}
.file-type-badge.aistudio {
background: #2a2b2e;
color: #f1f1f3;
}
[data-theme="dark"] .file-type-badge.aistudio {
background: #2a2b2e;
color: #d8dbe1;
}
.file-type-badge.claude {
background: #fce4ec;
color: #c2185b;
@@ -1671,18 +1837,39 @@ input:checked+.slider:before {
}
/* 未知类型通用样式 */
.file-type-badge:not(.qwen):not(.gemini):not(.claude):not(.codex):not(.iflow):not(.empty) {
.file-type-badge:not(.qwen):not(.gemini):not(.gemini-cli):not(.aistudio):not(.claude):not(.codex):not(.iflow):not(.empty) {
background: #f0f0f0;
color: #666666;
border: 1px dashed #999999;
}
[data-theme="dark"] .file-type-badge:not(.qwen):not(.gemini):not(.claude):not(.codex):not(.iflow):not(.empty) {
[data-theme="dark"] .file-type-badge:not(.qwen):not(.gemini):not(.gemini-cli):not(.aistudio):not(.claude):not(.codex):not(.iflow):not(.empty) {
background: #3a3a3a;
color: #aaaaaa;
border: 1px dashed #666666;
}
/* 虚拟认证文件标记样式 */
.virtual-auth-badge {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-hover) 100%);
color: white;
font-size: 13px;
padding: 4px 8px;
border-radius: 12px;
font-weight: 500;
text-align: center;
display: inline-block;
width: 100%;
box-shadow: 0 2px 4px rgba(59, 130, 246, 0.25);
letter-spacing: 0.3px;
}
[data-theme="dark"] .virtual-auth-badge {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-hover) 100%);
box-shadow: 0 2px 4px rgba(96, 165, 250, 0.25);
}
.provider-list {
/* 默认不限制高度,动态设置 */
min-height: 0;
@@ -1751,6 +1938,22 @@ input:checked+.slider:before {
justify-content: flex-end;
}
/* 认证文件项的底部布局优化 - 确保状态标签和操作按钮垂直对齐 */
.file-item .item-footer {
align-items: center; /* 垂直居中对齐 */
gap: 12px; /* 增加状态标签和按钮之间的间距 */
}
.file-item .item-stats {
align-items: center; /* 状态徽章垂直居中 */
margin: 0; /* 移除默认外边距 */
}
.file-item .item-actions {
align-items: center; /* 操作按钮垂直居中 */
gap: 6px; /* 按钮之间适当间距 */
}
/* API Keys 和 AI Providers 的按钮组 - 绝对定位到右侧垂直居中 */
.key-item .item-actions,
.provider-item .item-actions {
@@ -2039,9 +2242,13 @@ input:checked+.slider:before {
border-radius: 15px;
width: 90%;
max-width: 550px;
max-height: 90vh;
box-shadow: var(--shadow-modal);
animation: modalSlideIn 0.3s ease;
position: relative;
display: flex;
flex-direction: column;
overflow: hidden;
}
@keyframes modalSlideIn {
@@ -2082,6 +2289,9 @@ input:checked+.slider:before {
#modal-body {
padding: 35px 30px 30px 30px;
flex: 1;
min-height: 0;
overflow-y: auto;
}
/* 模态框标题样式 */
@@ -2513,6 +2723,42 @@ input:checked+.slider:before {
margin-bottom: 30px;
}
.usage-filter-bar {
display: flex;
justify-content: flex-start;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 20px;
}
.usage-filter-group {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 220px;
}
.usage-filter-group label {
font-weight: 600;
color: var(--text-secondary);
font-size: 14px;
}
.model-filter-select {
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 10px 14px;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 40px;
font-size: 14px;
}
.model-filter-select:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.stat-card {
background: var(--card-bg);
border: 1px solid var(--border-color);
@@ -2750,6 +2996,7 @@ input:checked+.slider:before {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 10px;
}
.model-input-row .input-group {
@@ -2770,6 +3017,102 @@ input:checked+.slider:before {
align-self: center;
}
.api-key-input-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 10px;
}
.api-key-input-group {
display: flex;
gap: 8px;
width: 100%;
}
.api-key-input-group .api-key-value-input {
flex: 2;
}
.api-key-input-group .api-key-proxy-input {
flex: 1;
min-width: 180px;
}
.api-key-input-group .api-key-remove-btn {
align-self: center;
}
.api-key-input-list + .btn,
.model-input-list + .btn,
.header-input-list + .btn {
margin-top: 4px;
align-self: flex-start;
}
.header-input-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 10px;
}
.header-input-row .header-input-group {
display: flex;
align-items: center;
gap: 8px;
}
.header-key-input {
flex: 1;
}
.header-value-input {
flex: 2;
}
.header-separator {
color: var(--text-tertiary);
}
.header-badges-wrapper {
display: flex;
flex-direction: column;
gap: 4px;
}
.header-badge-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.header-badge {
background: var(--accent-tertiary);
border: 1px solid var(--border-primary);
border-radius: 12px;
padding: 4px 8px;
font-size: 12px;
color: var(--text-secondary);
display: inline-flex;
gap: 4px;
}
.header-badge strong {
font-weight: 600;
color: var(--text-primary);
}
[data-theme="dark"] .header-badge {
background: rgba(59, 130, 246, 0.15);
border-color: rgba(59, 130, 246, 0.3);
color: var(--text-secondary);
}
[data-theme="dark"] .header-badge strong {
color: var(--text-secondary);
}
/* Codex OAuth 样式 */
#codex-oauth-content {
transition: all 0.3s ease;
@@ -3280,9 +3623,10 @@ input:checked+.slider:before {
border: 1px solid var(--border-primary);
border-radius: 6px;
padding: 16px;
font-size: 12px;
font-size: 13px;
line-height: 1.6;
color: var(--text-secondary);
font-weight: 600;
white-space: pre-wrap;
word-wrap: break-word;
max-height: calc(100vh - 480px);
@@ -3291,6 +3635,133 @@ input:checked+.slider:before {
margin: 0;
}
.logs-text .log-line {
display: block;
padding: 2px 0;
margin: 0;
color: inherit;
}
.logs-text .log-line + .log-line {
margin-top: 2px;
}
.logs-text .log-status-tag {
display: inline-block;
padding: 0 8px;
margin-right: 6px;
border-radius: 6px;
font-weight: 700;
letter-spacing: 0.3px;
}
.logs-text .log-status-tag.log-status-2xx {
background: rgba(16, 185, 129, 0.18);
color: #047857;
}
.logs-text .log-status-tag.log-status-3xx {
background: rgba(6, 182, 212, 0.2);
color: #0f766e;
}
.logs-text .log-status-tag.log-status-4xx {
background: rgba(251, 191, 36, 0.26);
color: #92400e;
}
.logs-text .log-status-tag.log-status-5xx {
background: rgba(239, 68, 68, 0.22);
color: #b91c1c;
}
.logs-text .log-status-tag.log-status-1xx {
background: rgba(165, 180, 252, 0.22);
color: #4338ca;
}
/* 日志高亮样式 */
/* 时间戳高亮 */
.logs-text .log-timestamp {
color: #8b5cf6;
font-weight: 600;
}
/* 日志级别高亮 */
.logs-text .log-level {
display: inline-block;
padding: 1px 6px;
border-radius: 4px;
font-weight: 700;
font-size: 0.95em;
letter-spacing: 0.5px;
}
.logs-text .log-level-error {
background: rgba(239, 68, 68, 0.20);
color: #dc2626;
}
.logs-text .log-level-warn {
background: rgba(251, 191, 36, 0.25);
color: #d97706;
}
.logs-text .log-level-info {
background: rgba(59, 130, 246, 0.20);
color: #2563eb;
}
.logs-text .log-level-debug {
background: rgba(107, 114, 128, 0.18);
color: #6b7280;
}
/* HTTP 方法高亮 */
.logs-text .log-http-method {
color: #0891b2;
font-weight: 700;
}
/* 路径/URL 高亮 */
.logs-text .log-path {
color: #059669;
text-decoration: underline;
text-decoration-style: dotted;
text-decoration-color: rgba(5, 150, 105, 0.3);
}
/* IP 地址高亮 */
.logs-text .log-ip {
color: #7c3aed;
font-weight: 600;
}
/* 关键字高亮 - 成功 */
.logs-text .log-keyword-success {
color: #059669;
font-weight: 600;
}
/* 关键字高亮 - 错误 */
.logs-text .log-keyword-error {
color: #dc2626;
font-weight: 600;
}
/* 关键字高亮 - 警告 */
.logs-text .log-keyword-warn {
color: #ea580c;
font-weight: 600;
}
/* 数字和单位高亮 */
.logs-text .log-number-unit {
color: #db2777;
font-weight: 600;
}
.logs-text::-webkit-scrollbar {
width: 8px;
height: 8px;
@@ -3424,10 +3895,143 @@ input:checked+.slider:before {
color: var(--text-tertiary);
}
[data-theme="dark"] .logs-text .log-status-tag.log-status-2xx {
background: rgba(16, 185, 129, 0.32);
color: #6ee7b7;
}
[data-theme="dark"] .logs-text .log-status-tag.log-status-3xx {
background: rgba(6, 182, 212, 0.34);
color: #5eead4;
}
[data-theme="dark"] .logs-text .log-status-tag.log-status-4xx {
background: rgba(251, 191, 36, 0.38);
color: #fcd34d;
}
[data-theme="dark"] .logs-text .log-status-tag.log-status-5xx {
background: rgba(239, 68, 68, 0.38);
color: #fca5a5;
}
[data-theme="dark"] .logs-text .log-status-tag.log-status-1xx {
background: rgba(165, 180, 252, 0.38);
color: #c7d2fe;
}
[data-theme="dark"] .logs-container {
background: rgba(15, 23, 42, 0.3);
}
/* 暗色主题 - 日志高亮样式 */
[data-theme="dark"] .logs-text .log-timestamp {
color: #c4b5fd;
}
[data-theme="dark"] .logs-text .log-level-error {
background: rgba(239, 68, 68, 0.32);
color: #fca5a5;
}
[data-theme="dark"] .logs-text .log-level-warn {
background: rgba(251, 191, 36, 0.35);
color: #fbbf24;
}
[data-theme="dark"] .logs-text .log-level-info {
background: rgba(59, 130, 246, 0.32);
color: #93c5fd;
}
[data-theme="dark"] .logs-text .log-level-debug {
background: rgba(107, 114, 128, 0.28);
color: #9ca3af;
}
[data-theme="dark"] .logs-text .log-http-method {
color: #22d3ee;
}
[data-theme="dark"] .logs-text .log-path {
color: #34d399;
text-decoration-color: rgba(52, 211, 153, 0.3);
}
[data-theme="dark"] .logs-text .log-ip {
color: #a78bfa;
}
[data-theme="dark"] .logs-text .log-keyword-success {
color: #34d399;
}
[data-theme="dark"] .logs-text .log-keyword-error {
color: #f87171;
}
[data-theme="dark"] .logs-text .log-keyword-warn {
color: #fb923c;
}
[data-theme="dark"] .logs-text .log-number-unit {
color: #f472b6;
}
/* Vertex AI Credential Import */
.vertex-import-actions {
text-align: left;
margin-top: 10px;
}
.vertex-import-result {
margin-top: 20px;
border: 1px dashed var(--border-primary);
border-radius: 12px;
padding: 16px;
background: var(--bg-quaternary);
color: var(--text-primary);
}
.vertex-import-result-header {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
margin-bottom: 10px;
color: var(--success-text);
}
.vertex-import-result ul {
list-style: none;
margin: 0;
padding: 0;
}
.vertex-import-result li {
margin-bottom: 6px;
font-size: 14px;
color: var(--text-secondary);
}
.vertex-import-result code {
background: var(--bg-secondary);
border-radius: 6px;
padding: 2px 6px;
font-size: 13px;
color: var(--text-primary);
}
[data-theme="dark"] .vertex-import-result {
border-color: rgba(96, 165, 250, 0.4);
background: rgba(15, 23, 42, 0.4);
}
[data-theme="dark"] .vertex-import-result code {
background: rgba(7, 11, 22, 0.8);
color: #f3f4f6;
}
/* ===== AI提供商统计徽章样式 ===== */
/* 统计信息容器 */
@@ -3435,6 +4039,7 @@ input:checked+.slider:before {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 8px;
}
/* 统计徽章基础样式 */