From f691380482b41fc9ff1605ce836af9582eb288c9 Mon Sep 17 00:00:00 2001 From: hex2077 Date: Sat, 3 Jan 2026 18:02:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E4=B8=BB=E9=A2=98?= =?UTF-8?q?=E5=88=87=E6=8D=A2=E5=8A=9F=E8=83=BD=E5=B9=B6=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E6=8F=90=E4=BE=9B=E5=95=86=E6=B1=A0=E5=88=9D=E5=A7=8B=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refactor: 重构配置管理移除冗余提供商配置 fix: 修复手动OAuth回调处理逻辑 style: 优化用量卡片UI增加折叠功能 perf: 提升服务启动时提供商池节点初始化效率 docs: 更新i18n翻译文本和配置说明 chore: 清理无用代码和配置文件 --- configs/config.json.example | 21 - src/api-server.js | 7 +- src/config-manager.js | 178 +------- src/provider-pool-manager.js | 1 + src/service-manager.js | 90 ++-- src/ui-manager.js | 124 ++++-- static/app/config-manager.js | 195 ++------ static/app/i18n.js | 16 +- static/app/language-switcher.js | 4 +- static/app/provider-manager.js | 53 ++- static/app/styles.css | 762 +++++++++++++++++++++++++++++++- static/app/theme-switcher.js | 118 +++++ static/app/usage-manager.js | 98 +++- static/index.html | 248 ++--------- 14 files changed, 1263 insertions(+), 652 deletions(-) create mode 100644 static/app/theme-switcher.js diff --git a/configs/config.json.example b/configs/config.json.example index 180d4db..6c3d8f7 100644 --- a/configs/config.json.example +++ b/configs/config.json.example @@ -3,17 +3,6 @@ "SERVER_PORT": 3000, "HOST": "0.0.0.0", "MODEL_PROVIDER": "gemini-cli-oauth", - "OPENAI_API_KEY": "xxx", - "OPENAI_BASE_URL": "https://openai/v1", - "CLAUDE_API_KEY": "xxx", - "CLAUDE_BASE_URL": "https://anthropic/v1", - "PROJECT_ID": null, - "GEMINI_OAUTH_CREDS_BASE64": null, - "GEMINI_OAUTH_CREDS_FILE_PATH": null, - "ANTIGRAVITY_OAUTH_CREDS_FILE_PATH": null, - "KIRO_OAUTH_CREDS_BASE64": null, - "KIRO_OAUTH_CREDS_FILE_PATH": null, - "QWEN_OAUTH_CREDS_FILE_PATH": null, "SYSTEM_PROMPT_FILE_PATH": "configs/input_system_prompt.txt", "SYSTEM_PROMPT_MODE": "overwrite", "PROMPT_LOG_BASE_NAME": "prompt_log", @@ -24,16 +13,6 @@ "CRON_REFRESH_TOKEN": false, "PROVIDER_POOLS_FILE_PATH": "configs/provider_pools.json", "MAX_ERROR_COUNT": 3, - "QWEN_BASE_URL": "https://portal.qwen.ai/v1", - "QWEN_OAUTH_BASE_URL": "https://chat.qwen.ai", - "GEMINI_BASE_URL": "https://cloudcode-pa.googleapis.com", - "ANTIGRAVITY_BASE_URL_DAILY": "https://daily-cloudcode-pa.sandbox.googleapis.com", - "ANTIGRAVITY_BASE_URL_AUTOPUSH": "https://autopush-cloudcode-pa.sandbox.googleapis.com", - "KIRO_REFRESH_URL": "https://prod.{{region}}.auth.desktop.kiro.dev/refreshToken", - "KIRO_REFRESH_IDC_URL": "https://oidc.{{region}}.amazonaws.com/token", - "KIRO_BASE_URL": "https://codewhisperer.{{region}}.amazonaws.com/generateAssistantResponse", - "KIRO_AMAZON_Q_URL": "https://codewhisperer.{{region}}.amazonaws.com/SendMessageStreaming", - "KIRO_USAGE_LIMITS_URL": "https://q.{{region}}.amazonaws.com/getUsageLimits", "providerFallbackChain": { "gemini-cli-oauth": ["gemini-antigravity"], "gemini-antigravity": ["gemini-cli-oauth"], diff --git a/src/api-server.js b/src/api-server.js index e7d8a66..342fac0 100644 --- a/src/api-server.js +++ b/src/api-server.js @@ -1,5 +1,5 @@ import * as http from 'http'; -import { initializeConfig, CONFIG, logProviderSpecificDetails } from './config-manager.js'; +import { initializeConfig, CONFIG } from './config-manager.js'; import { initApiService, autoLinkProviderConfigs } from './service-manager.js'; import { initializeUIManagement } from './ui-manager.js'; import { initializeAPIManagement } from './api-manager.js'; @@ -247,7 +247,6 @@ async function startServer() { if (uniqueProviders.length > 1) { console.log(` Additional Model Providers: ${uniqueProviders.slice(1).join(', ')}`); } - uniqueProviders.forEach((provider) => logProviderSpecificDetails(provider, CONFIG)); console.log(` System Prompt File: ${CONFIG.SYSTEM_PROMPT_FILE_PATH || 'Default'}`); console.log(` System Prompt Mode: ${CONFIG.SYSTEM_PROMPT_MODE}`); console.log(` Host: ${CONFIG.HOST}`); @@ -267,6 +266,8 @@ async function startServer() { // if (CONFIG.HOST === '0.0.0.0' || CONFIG.HOST === '127.0.0.1') { try { const open = (await import('open')).default; + // 作为子进程启动时,需要更长的延迟确保服务完全就绪 + const openDelay = IS_WORKER_PROCESS ? 3000 : 1000; setTimeout(() => { let openUrl = `http://${CONFIG.HOST}:${CONFIG.SERVER_PORT}/login.html`; if(CONFIG.HOST === '0.0.0.0'){ @@ -279,7 +280,7 @@ async function startServer() { .catch(err => { console.log('[UI] Please open manually: http://' + CONFIG.HOST + ':' + CONFIG.SERVER_PORT + '/login.html'); }); - }, 1000); + }, openDelay); } catch (err) { console.log(`[UI] Login page available at: http://${CONFIG.HOST}:${CONFIG.SERVER_PORT}/login.html`); } diff --git a/src/config-manager.js b/src/config-manager.js index d54841c..b591dc1 100644 --- a/src/config-manager.js +++ b/src/config-manager.js @@ -67,25 +67,6 @@ export async function initializeConfig(args = process.argv.slice(2), configFileP SERVER_PORT: 3000, HOST: '0.0.0.0', MODEL_PROVIDER: MODEL_PROVIDER.GEMINI_CLI, - OPENAI_API_KEY: null, - OPENAI_BASE_URL: null, - CLAUDE_API_KEY: null, - CLAUDE_BASE_URL: null, - GEMINI_OAUTH_CREDS_BASE64: null, - GEMINI_OAUTH_CREDS_FILE_PATH: null, - KIRO_OAUTH_CREDS_BASE64: null, - KIRO_OAUTH_CREDS_FILE_PATH: null, - QWEN_OAUTH_CREDS_FILE_PATH: null, - PROJECT_ID: null, - // Provider URLs - QWEN_BASE_URL: null, - QWEN_OAUTH_BASE_URL: null, - GEMINI_BASE_URL: null, - ANTIGRAVITY_BASE_URL_DAILY: null, - ANTIGRAVITY_BASE_URL_AUTOPUSH: null, - KIRO_REFRESH_URL: null, - KIRO_REFRESH_IDC_URL: null, - KIRO_BASE_URL: null, SYSTEM_PROMPT_FILE_PATH: INPUT_SYSTEM_PROMPT_FILE, // Default value SYSTEM_PROMPT_MODE: 'append', PROMPT_LOG_BASE_NAME: "prompt_log", @@ -136,99 +117,6 @@ export async function initializeConfig(args = process.argv.slice(2), configFileP } else { console.warn(`[Config Warning] --model-provider flag requires a value.`); } - } else if (args[i] === '--openai-api-key') { - if (i + 1 < args.length) { - currentConfig.OPENAI_API_KEY = args[i + 1]; - i++; - } else { - console.warn(`[Config Warning] --openai-api-key flag requires a value.`); - } - } else if (args[i] === '--openai-base-url') { - if (i + 1 < args.length) { - currentConfig.OPENAI_BASE_URL = args[i + 1]; - i++; - } else { - console.warn(`[Config Warning] --openai-base-url flag requires a value.`); - } - } else if (args[i] === '--claude-api-key') { - if (i + 1 < args.length) { - currentConfig.CLAUDE_API_KEY = args[i + 1]; - i++; - } else { - console.warn(`[Config Warning] --claude-api-key flag requires a value.`); - } - } else if (args[i] === '--claude-base-url') { - if (i + 1 < args.length) { - currentConfig.CLAUDE_BASE_URL = args[i + 1]; - i++; - } else { - console.warn(`[Config Warning] --claude-base-url flag requires a value.`); - } - } - // Provider URL arguments - else if (args[i] === '--qwen-base-url') { - if (i + 1 < args.length) { - currentConfig.QWEN_BASE_URL = args[i + 1]; - i++; - } - } else if (args[i] === '--qwen-oauth-base-url') { - if (i + 1 < args.length) { - currentConfig.QWEN_OAUTH_BASE_URL = args[i + 1]; - i++; - } - } else if (args[i] === '--gemini-base-url') { - if (i + 1 < args.length) { - currentConfig.GEMINI_BASE_URL = args[i + 1]; - i++; - } - } else if (args[i] === '--antigravity-base-url-daily') { - if (i + 1 < args.length) { - currentConfig.ANTIGRAVITY_BASE_URL_DAILY = args[i + 1]; - i++; - } - } else if (args[i] === '--antigravity-base-url-autopush') { - if (i + 1 < args.length) { - currentConfig.ANTIGRAVITY_BASE_URL_AUTOPUSH = args[i + 1]; - i++; - } - } else if (args[i] === '--kiro-refresh-url') { - if (i + 1 < args.length) { - currentConfig.KIRO_REFRESH_URL = args[i + 1]; - i++; - } - } else if (args[i] === '--kiro-refresh-idc-url') { - if (i + 1 < args.length) { - currentConfig.KIRO_REFRESH_IDC_URL = args[i + 1]; - i++; - } - } else if (args[i] === '--kiro-base-url') { - if (i + 1 < args.length) { - currentConfig.KIRO_BASE_URL = args[i + 1]; - i++; - } - } - // Gemini-specific arguments - else if (args[i] === '--gemini-oauth-creds-base64') { - if (i + 1 < args.length) { - currentConfig.GEMINI_OAUTH_CREDS_BASE64 = args[i + 1]; - i++; - } else { - console.warn(`[Config Warning] --gemini-oauth-creds-base64 flag requires a value.`); - } - } else if (args[i] === '--gemini-oauth-creds-file') { - if (i + 1 < args.length) { - currentConfig.GEMINI_OAUTH_CREDS_FILE_PATH = args[i + 1]; - i++; - } else { - console.warn(`[Config Warning] --gemini-oauth-creds-file flag requires a value.`); - } - } else if (args[i] === '--project-id') { - if (i + 1 < args.length) { - currentConfig.PROJECT_ID = args[i + 1]; - i++; - } else { - console.warn(`[Config Warning] --project-id flag requires a value.`); - } } else if (args[i] === '--system-prompt-file') { if (i + 1 < args.length) { currentConfig.SYSTEM_PROMPT_FILE_PATH = args[i + 1]; @@ -262,28 +150,7 @@ export async function initializeConfig(args = process.argv.slice(2), configFileP } else { console.warn(`[Config Warning] --prompt-log-base-name flag requires a value.`); } - } else if (args[i] === '--kiro-oauth-creds-base64') { - if (i + 1 < args.length) { - currentConfig.KIRO_OAUTH_CREDS_BASE64 = args[i + 1]; - i++; - } else { - console.warn(`[Config Warning] --kiro-oauth-creds-base64 flag requires a value.`); - } - } else if (args[i] === '--kiro-oauth-creds-file') { - if (i + 1 < args.length) { - currentConfig.KIRO_OAUTH_CREDS_FILE_PATH = args[i + 1]; - i++; - } else { - console.warn(`[Config Warning] --kiro-oauth-creds-file flag requires a value.`); - } - } else if (args[i] === '--qwen-oauth-creds-file') { - if (i + 1 < args.length) { - currentConfig.QWEN_OAUTH_CREDS_FILE_PATH = args[i + 1]; - i++; - } else { - console.warn(`[Config Warning] --qwen-oauth-creds-file flag requires a value.`); - } - } else if (args[i] === '--cron-near-minutes') { + } else if (args[i] === '--cron-near-minutes') { if (i + 1 < args.length) { currentConfig.CRON_NEAR_MINUTES = parseInt(args[i + 1], 10); i++; @@ -383,46 +250,5 @@ export async function getSystemPromptFileContent(filePath) { } } -/** - * Logs provider-specific configuration details - * @param {string} provider - The model provider - * @param {Object} config - The configuration object - */ -export function logProviderSpecificDetails(provider, config) { - switch (provider) { - case MODEL_PROVIDER.OPENAI_CUSTOM: - console.log(` [openai-custom] API Key: ${config.OPENAI_API_KEY ? '******' : 'Not Set'}`); - console.log(` [openai-custom] Base URL: ${config.OPENAI_BASE_URL || 'Default'}`); - break; - case MODEL_PROVIDER.CLAUDE_CUSTOM: - console.log(` [claude-custom] API Key: ${config.CLAUDE_API_KEY ? '******' : 'Not Set'}`); - console.log(` [claude-custom] Base URL: ${config.CLAUDE_BASE_URL || 'Default'}`); - break; - case MODEL_PROVIDER.GEMINI_CLI: - if (config.GEMINI_OAUTH_CREDS_FILE_PATH) { - console.log(` [gemini-cli-oauth] OAuth Creds File Path: ${config.GEMINI_OAUTH_CREDS_FILE_PATH}`); - } else if (config.GEMINI_OAUTH_CREDS_BASE64) { - console.log(` [gemini-cli-oauth] OAuth Creds Source: Provided via Base64 string`); - } else { - console.log(` [gemini-cli-oauth] OAuth Creds: Default discovery`); - } - // console.log(` [gemini-cli-oauth] Project ID: ${config.PROJECT_ID || 'Auto-discovered'}`); - break; - case MODEL_PROVIDER.KIRO_API: - if (config.KIRO_OAUTH_CREDS_FILE_PATH) { - console.log(` [claude-kiro-oauth] OAuth Creds File Path: ${config.KIRO_OAUTH_CREDS_FILE_PATH}`); - } else if (config.KIRO_OAUTH_CREDS_BASE64) { - console.log(` [claude-kiro-oauth] OAuth Creds Source: Provided via Base64 string`); - } else { - console.log(` [claude-kiro-oauth] OAuth Creds: Default`); - } - break; - case MODEL_PROVIDER.QWEN_API: - console.log(` [openai-qwen-oauth] OAuth Creds File Path: ${config.QWEN_OAUTH_CREDS_FILE_PATH || 'Default'}`); - break; - default: - console.log(` [${provider}] Provider initialized.`); - } -} - export { ALL_MODEL_PROVIDERS }; + diff --git a/src/provider-pool-manager.js b/src/provider-pool-manager.js index 4798a49..4de8263 100644 --- a/src/provider-pool-manager.js +++ b/src/provider-pool-manager.js @@ -93,6 +93,7 @@ export class ProviderPoolManager { providerConfig.lastHealthCheckTime = providerConfig.lastHealthCheckTime || null; providerConfig.lastHealthCheckModel = providerConfig.lastHealthCheckModel || null; providerConfig.lastErrorMessage = providerConfig.lastErrorMessage || null; + providerConfig.customName = providerConfig.customName || null; this.providerStatus[providerType].push({ config: providerConfig, diff --git a/src/service-manager.js b/src/service-manager.js index 6b4e750..5ba97dd 100644 --- a/src/service-manager.js +++ b/src/service-manager.js @@ -177,36 +177,59 @@ export async function initApiService(config) { console.log('[Initialization] No provider pools configured. Using single provider mode.'); } - // Initialize configured service adapters at startup - // 对于未纳入号池的提供者,提前初始化以避免首个请求的额外延迟 - const providersToInit = new Set(); - if (Array.isArray(config.DEFAULT_MODEL_PROVIDERS)) { - config.DEFAULT_MODEL_PROVIDERS.forEach((provider) => providersToInit.add(provider)); - } - if (config.providerPools) { - Object.keys(config.providerPools).forEach((provider) => providersToInit.add(provider)); - } - if (providersToInit.size === 0) { - const { ALL_MODEL_PROVIDERS } = await import('./config-manager.js'); - ALL_MODEL_PROVIDERS.forEach((provider) => providersToInit.add(provider)); - } - - for (const provider of providersToInit) { - const { ALL_MODEL_PROVIDERS } = await import('./config-manager.js'); - if (!ALL_MODEL_PROVIDERS.includes(provider)) { - console.warn(`[Initialization Warning] Skipping unknown model provider '${provider}' during adapter initialization.`); - continue; - } - if (config.providerPools && config.providerPools[provider] && config.providerPools[provider].length > 0) { - // 由号池管理器负责按需初始化 - continue; - } - try { - console.log(`[Initialization] Initializing single service adapter for ${provider}...`); - getServiceAdapter({ ...config, MODEL_PROVIDER: provider }); - } catch (error) { - console.warn(`[Initialization Warning] Failed to initialize single service adapter for ${provider}: ${error.message}`); + // Initialize all provider pool nodes at startup + // 初始化号池中所有提供商的所有节点,以避免首个请求的额外延迟 + if (config.providerPools && Object.keys(config.providerPools).length > 0) { + let totalInitialized = 0; + let totalFailed = 0; + + for (const [providerType, providerConfigs] of Object.entries(config.providerPools)) { + // 验证提供商类型是否在 DEFAULT_MODEL_PROVIDERS 中 + if (config.DEFAULT_MODEL_PROVIDERS && Array.isArray(config.DEFAULT_MODEL_PROVIDERS)) { + if (!config.DEFAULT_MODEL_PROVIDERS.includes(providerType)) { + console.log(`[Initialization] Skipping provider type '${providerType}' (not in DEFAULT_MODEL_PROVIDERS).`); + continue; + } + } + + if (!Array.isArray(providerConfigs) || providerConfigs.length === 0) { + continue; + } + + console.log(`[Initialization] Initializing ${providerConfigs.length} node(s) for provider '${providerType}'...`); + + // 初始化该提供商类型的所有节点 + for (const providerConfig of providerConfigs) { + // 跳过已禁用的节点 + if (providerConfig.isDisabled) { + continue; + } + + try { + // 合并全局配置和节点配置 + const nodeConfig = deepmerge(config, { + ...providerConfig, + MODEL_PROVIDER: providerType + }); + delete nodeConfig.providerPools; // 移除 providerPools 避免递归 + + // 初始化服务适配器 + getServiceAdapter(nodeConfig); + totalInitialized++; + + const identifier = providerConfig.customName || providerConfig.uuid || 'unknown'; + console.log(` ✓ Initialized node: ${identifier}`); + } catch (error) { + totalFailed++; + const identifier = providerConfig.customName || providerConfig.uuid || 'unknown'; + console.warn(` ✗ Failed to initialize node ${identifier}: ${error.message}`); + } + } } + + console.log(`[Initialization] Provider pool initialization complete: ${totalInitialized} succeeded, ${totalFailed} failed.`); + } else { + console.log('[Initialization] No provider pools configured. Skipping node initialization.'); } return serviceInstances; // Return the collection of initialized service instances } @@ -231,10 +254,11 @@ export async function getApiService(config, requestedModel = null, options = {}) config.uuid = serviceConfig.uuid; console.log(`[API Service] Using pooled configuration for ${config.MODEL_PROVIDER}: ${serviceConfig.uuid}${requestedModel ? ` (model: ${requestedModel})` : ''}`); } else { - console.warn(`[API Service] No healthy provider found in pool for ${config.MODEL_PROVIDER}${requestedModel ? ` supporting model: ${requestedModel}` : ''}. Falling back to main config.`); + const errorMsg = `[API Service] No healthy provider found in pool for ${config.MODEL_PROVIDER}${requestedModel ? ` supporting model: ${requestedModel}` : ''}`; + console.error(errorMsg); + throw new Error(errorMsg); } } - // 号池不可用时降级,直接使用当前请求的 config 初始化服务适配器 return getServiceAdapter(serviceConfig); } @@ -273,6 +297,10 @@ export async function getApiServiceWithFallback(config, requestedModel = null, o if (isFallback) { serviceConfig.MODEL_PROVIDER = actualProviderType; } + } else { + const errorMsg = `[API Service] No healthy provider found in pool (including fallback) for ${config.MODEL_PROVIDER}${requestedModel ? ` supporting model: ${requestedModel}` : ''}`; + console.error(errorMsg); + throw new Error(errorMsg); } } diff --git a/src/ui-manager.js b/src/ui-manager.js index f9e22d7..ed2e4f0 100644 --- a/src/ui-manager.js +++ b/src/ui-manager.js @@ -690,26 +690,6 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo if (newConfig.HOST !== undefined) currentConfig.HOST = newConfig.HOST; if (newConfig.SERVER_PORT !== undefined) currentConfig.SERVER_PORT = newConfig.SERVER_PORT; if (newConfig.MODEL_PROVIDER !== undefined) currentConfig.MODEL_PROVIDER = newConfig.MODEL_PROVIDER; - if (newConfig.PROJECT_ID !== undefined) currentConfig.PROJECT_ID = newConfig.PROJECT_ID; - if (newConfig.OPENAI_API_KEY !== undefined) currentConfig.OPENAI_API_KEY = newConfig.OPENAI_API_KEY; - if (newConfig.OPENAI_BASE_URL !== undefined) currentConfig.OPENAI_BASE_URL = newConfig.OPENAI_BASE_URL; - if (newConfig.CLAUDE_API_KEY !== undefined) currentConfig.CLAUDE_API_KEY = newConfig.CLAUDE_API_KEY; - if (newConfig.CLAUDE_BASE_URL !== undefined) currentConfig.CLAUDE_BASE_URL = newConfig.CLAUDE_BASE_URL; - if (newConfig.GEMINI_OAUTH_CREDS_BASE64 !== undefined) currentConfig.GEMINI_OAUTH_CREDS_BASE64 = newConfig.GEMINI_OAUTH_CREDS_BASE64; - if (newConfig.GEMINI_OAUTH_CREDS_FILE_PATH !== undefined) currentConfig.GEMINI_OAUTH_CREDS_FILE_PATH = newConfig.GEMINI_OAUTH_CREDS_FILE_PATH; - if (newConfig.KIRO_OAUTH_CREDS_BASE64 !== undefined) currentConfig.KIRO_OAUTH_CREDS_BASE64 = newConfig.KIRO_OAUTH_CREDS_BASE64; - if (newConfig.KIRO_OAUTH_CREDS_FILE_PATH !== undefined) currentConfig.KIRO_OAUTH_CREDS_FILE_PATH = newConfig.KIRO_OAUTH_CREDS_FILE_PATH; - if (newConfig.QWEN_OAUTH_CREDS_FILE_PATH !== undefined) currentConfig.QWEN_OAUTH_CREDS_FILE_PATH = newConfig.QWEN_OAUTH_CREDS_FILE_PATH; - - // New Provider URLs - if (newConfig.QWEN_BASE_URL !== undefined) currentConfig.QWEN_BASE_URL = newConfig.QWEN_BASE_URL; - if (newConfig.QWEN_OAUTH_BASE_URL !== undefined) currentConfig.QWEN_OAUTH_BASE_URL = newConfig.QWEN_OAUTH_BASE_URL; - if (newConfig.GEMINI_BASE_URL !== undefined) currentConfig.GEMINI_BASE_URL = newConfig.GEMINI_BASE_URL; - if (newConfig.ANTIGRAVITY_BASE_URL_DAILY !== undefined) currentConfig.ANTIGRAVITY_BASE_URL_DAILY = newConfig.ANTIGRAVITY_BASE_URL_DAILY; - if (newConfig.ANTIGRAVITY_BASE_URL_AUTOPUSH !== undefined) currentConfig.ANTIGRAVITY_BASE_URL_AUTOPUSH = newConfig.ANTIGRAVITY_BASE_URL_AUTOPUSH; - if (newConfig.KIRO_REFRESH_URL !== undefined) currentConfig.KIRO_REFRESH_URL = newConfig.KIRO_REFRESH_URL; - if (newConfig.KIRO_REFRESH_IDC_URL !== undefined) currentConfig.KIRO_REFRESH_IDC_URL = newConfig.KIRO_REFRESH_IDC_URL; - if (newConfig.KIRO_BASE_URL !== undefined) currentConfig.KIRO_BASE_URL = newConfig.KIRO_BASE_URL; if (newConfig.SYSTEM_PROMPT_FILE_PATH !== undefined) currentConfig.SYSTEM_PROMPT_FILE_PATH = newConfig.SYSTEM_PROMPT_FILE_PATH; if (newConfig.SYSTEM_PROMPT_MODE !== undefined) currentConfig.SYSTEM_PROMPT_MODE = newConfig.SYSTEM_PROMPT_MODE; if (newConfig.PROMPT_LOG_BASE_NAME !== undefined) currentConfig.PROMPT_LOG_BASE_NAME = newConfig.PROMPT_LOG_BASE_NAME; @@ -753,27 +733,6 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo SERVER_PORT: currentConfig.SERVER_PORT, HOST: currentConfig.HOST, MODEL_PROVIDER: currentConfig.MODEL_PROVIDER, - OPENAI_API_KEY: currentConfig.OPENAI_API_KEY, - OPENAI_BASE_URL: currentConfig.OPENAI_BASE_URL, - CLAUDE_API_KEY: currentConfig.CLAUDE_API_KEY, - CLAUDE_BASE_URL: currentConfig.CLAUDE_BASE_URL, - PROJECT_ID: currentConfig.PROJECT_ID, - GEMINI_OAUTH_CREDS_BASE64: currentConfig.GEMINI_OAUTH_CREDS_BASE64, - GEMINI_OAUTH_CREDS_FILE_PATH: currentConfig.GEMINI_OAUTH_CREDS_FILE_PATH, - KIRO_OAUTH_CREDS_BASE64: currentConfig.KIRO_OAUTH_CREDS_BASE64, - KIRO_OAUTH_CREDS_FILE_PATH: currentConfig.KIRO_OAUTH_CREDS_FILE_PATH, - QWEN_OAUTH_CREDS_FILE_PATH: currentConfig.QWEN_OAUTH_CREDS_FILE_PATH, - // Provider URLs - QWEN_BASE_URL: currentConfig.QWEN_BASE_URL, - QWEN_OAUTH_BASE_URL: currentConfig.QWEN_OAUTH_BASE_URL, - GEMINI_BASE_URL: currentConfig.GEMINI_BASE_URL, - ANTIGRAVITY_BASE_URL_DAILY: currentConfig.ANTIGRAVITY_BASE_URL_DAILY, - ANTIGRAVITY_BASE_URL_AUTOPUSH: currentConfig.ANTIGRAVITY_BASE_URL_AUTOPUSH, - KIRO_REFRESH_URL: currentConfig.KIRO_REFRESH_URL, - KIRO_REFRESH_IDC_URL: currentConfig.KIRO_REFRESH_IDC_URL, - KIRO_BASE_URL: currentConfig.KIRO_BASE_URL, - KIRO_AMAZON_Q_URL: currentConfig.KIRO_AMAZON_Q_URL, - KIRO_USAGE_LIMITS_URL: currentConfig.KIRO_USAGE_LIMITS_URL, SYSTEM_PROMPT_FILE_PATH: currentConfig.SYSTEM_PROMPT_FILE_PATH, SYSTEM_PROMPT_MODE: currentConfig.SYSTEM_PROMPT_MODE, PROMPT_LOG_BASE_NAME: currentConfig.PROMPT_LOG_BASE_NAME, @@ -1498,6 +1457,84 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo } } + // Handle manual OAuth callback + if (method === 'POST' && pathParam === '/api/oauth/manual-callback') { + try { + const body = await getRequestBody(req); + const { provider, callbackUrl, authMethod } = body; + + if (!provider || !callbackUrl) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: false, + error: 'provider and callbackUrl are required' + })); + return true; + } + + console.log(`[OAuth Manual Callback] Processing manual callback for ${provider}`); + console.log(`[OAuth Manual Callback] Callback URL: ${callbackUrl}`); + + // 解析回调URL + const url = new URL(callbackUrl); + const code = url.searchParams.get('code'); + const token = url.searchParams.get('token'); + + if (!code && !token) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: false, + error: 'Callback URL must contain code or token parameter' + })); + return true; + } + + // 通过fetch请求本地OAuth回调服务器处理 + // 使用localhost而不是原始hostname,确保请求到达本地服务器 + const localUrl = new URL(callbackUrl); + localUrl.hostname = 'localhost'; + localUrl.protocol = 'http:'; + + try { + const response = await fetch(localUrl.href); + + if (response.ok) { + console.log(`[OAuth Manual Callback] Successfully processed callback for ${provider}`); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: true, + message: 'OAuth callback processed successfully' + })); + } else { + const errorText = await response.text(); + console.error(`[OAuth Manual Callback] Callback processing failed:`, errorText); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: false, + error: `Callback processing failed: ${response.status}` + })); + } + } catch (fetchError) { + console.error(`[OAuth Manual Callback] Failed to process callback:`, fetchError); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: false, + error: `Failed to process callback: ${fetchError.message}` + })); + } + + return true; + } catch (error) { + console.error('[OAuth Manual Callback] Error:', error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: false, + error: error.message + })); + return true; + } + } + // Server-Sent Events for real-time updates if (method === 'GET' && pathParam === '/api/events') { res.writeHead(200, { @@ -2646,6 +2683,11 @@ async function getAdapterUsage(adapter, providerType) { * @returns {string} 显示名称 */ function getProviderDisplayName(provider, providerType) { + // 优先使用自定义名称 + if (provider.customName) { + return provider.customName; + } + // 尝试从凭据文件路径提取名称 const credPathKey = { 'claude-kiro-oauth': 'KIRO_OAUTH_CREDS_FILE_PATH', diff --git a/static/app/config-manager.js b/static/app/config-manager.js index 40ae07d..9e43f5a 100644 --- a/static/app/config-manager.js +++ b/static/app/config-manager.js @@ -22,65 +22,41 @@ async function loadConfiguration() { if (apiKeyEl) apiKeyEl.value = data.REQUIRED_API_KEY || ''; if (hostEl) hostEl.value = data.HOST || '127.0.0.1'; if (portEl) portEl.value = data.SERVER_PORT || 3000; - if (modelProviderEl) modelProviderEl.value = data.MODEL_PROVIDER || 'gemini-cli-oauth'; + + if (modelProviderEl) { + // 处理多选 MODEL_PROVIDER (复选框) + const providers = Array.isArray(data.DEFAULT_MODEL_PROVIDERS) + ? data.DEFAULT_MODEL_PROVIDERS + : (typeof data.MODEL_PROVIDER === 'string' ? data.MODEL_PROVIDER.split(',') : []); + + const checkboxes = modelProviderEl.querySelectorAll('input[type="checkbox"]'); + checkboxes.forEach(checkbox => { + checkbox.checked = providers.includes(checkbox.value); + }); + + // 如果没有任何选中的,默认选中第一个(保持兼容性) + const anyChecked = Array.from(checkboxes).some(cb => cb.checked); + if (!anyChecked && checkboxes.length > 0) { + checkboxes[0].checked = true; + } + + // 为复选框添加事件监听,防止取消勾选最后一个 + checkboxes.forEach(checkbox => { + // 移除旧的监听器(如果有的话,虽然这里大概率没有) + const newCheckbox = checkbox.cloneNode(true); + checkbox.parentNode.replaceChild(newCheckbox, checkbox); + + newCheckbox.addEventListener('change', (e) => { + const checkedCount = modelProviderEl.querySelectorAll('input[type="checkbox"]:checked').length; + if (checkedCount === 0) { + newCheckbox.checked = true; + showToast(t('common.warning'), t('config.modelProviderRequired'), 'warning'); + } + }); + }); + } + if (systemPromptEl) systemPromptEl.value = data.systemPrompt || ''; - - // Gemini CLI OAuth - const projectIdEl = document.getElementById('projectId'); - const geminiOauthCredsBase64El = document.getElementById('geminiOauthCredsBase64'); - const geminiOauthCredsFilePathEl = document.getElementById('geminiOauthCredsFilePath'); - - if (projectIdEl) projectIdEl.value = data.PROJECT_ID || ''; - if (geminiOauthCredsBase64El) geminiOauthCredsBase64El.value = data.GEMINI_OAUTH_CREDS_BASE64 || ''; - if (geminiOauthCredsFilePathEl) geminiOauthCredsFilePathEl.value = data.GEMINI_OAUTH_CREDS_FILE_PATH || ''; - const geminiBaseUrlEl = document.getElementById('geminiBaseUrl'); - if (geminiBaseUrlEl) geminiBaseUrlEl.value = data.GEMINI_BASE_URL || ''; - const antigravityBaseUrlDailyEl = document.getElementById('antigravityBaseUrlDaily'); - if (antigravityBaseUrlDailyEl) antigravityBaseUrlDailyEl.value = data.ANTIGRAVITY_BASE_URL_DAILY || ''; - const antigravityBaseUrlAutopushEl = document.getElementById('antigravityBaseUrlAutopush'); - if (antigravityBaseUrlAutopushEl) antigravityBaseUrlAutopushEl.value = data.ANTIGRAVITY_BASE_URL_AUTOPUSH || ''; - - // OpenAI Custom - const openaiApiKeyEl = document.getElementById('openaiApiKey'); - const openaiBaseUrlEl = document.getElementById('openaiBaseUrl'); - - if (openaiApiKeyEl) openaiApiKeyEl.value = data.OPENAI_API_KEY || ''; - if (openaiBaseUrlEl) openaiBaseUrlEl.value = data.OPENAI_BASE_URL || 'https://api.openai.com/v1'; - - // Claude Custom - const claudeApiKeyEl = document.getElementById('claudeApiKey'); - const claudeBaseUrlEl = document.getElementById('claudeBaseUrl'); - - if (claudeApiKeyEl) claudeApiKeyEl.value = data.CLAUDE_API_KEY || ''; - if (claudeBaseUrlEl) claudeBaseUrlEl.value = data.CLAUDE_BASE_URL || 'https://api.anthropic.com'; - - // Claude Kiro OAuth - const kiroOauthCredsBase64El = document.getElementById('kiroOauthCredsBase64'); - const kiroOauthCredsFilePathEl = document.getElementById('kiroOauthCredsFilePath'); - - if (kiroOauthCredsBase64El) kiroOauthCredsBase64El.value = data.KIRO_OAUTH_CREDS_BASE64 || ''; - if (kiroOauthCredsFilePathEl) kiroOauthCredsFilePathEl.value = data.KIRO_OAUTH_CREDS_FILE_PATH || ''; - const kiroBaseUrlEl = document.getElementById('kiroBaseUrl'); - if (kiroBaseUrlEl) kiroBaseUrlEl.value = data.KIRO_BASE_URL || ''; - const kiroRefreshUrlEl = document.getElementById('kiroRefreshUrl'); - if (kiroRefreshUrlEl) kiroRefreshUrlEl.value = data.KIRO_REFRESH_URL || ''; - const kiroRefreshIdcUrlEl = document.getElementById('kiroRefreshIdcUrl'); - if (kiroRefreshIdcUrlEl) kiroRefreshIdcUrlEl.value = data.KIRO_REFRESH_IDC_URL || ''; - - // Qwen OAuth - const qwenOauthCredsFilePathEl = document.getElementById('qwenOauthCredsFilePath'); - if (qwenOauthCredsFilePathEl) qwenOauthCredsFilePathEl.value = data.QWEN_OAUTH_CREDS_FILE_PATH || ''; - const qwenBaseUrlEl = document.getElementById('qwenBaseUrl'); - if (qwenBaseUrlEl) qwenBaseUrlEl.value = data.QWEN_BASE_URL || ''; - const qwenOauthBaseUrlEl = document.getElementById('qwenOauthBaseUrl'); - if (qwenOauthBaseUrlEl) qwenOauthBaseUrlEl.value = data.QWEN_OAUTH_BASE_URL || ''; - - // OpenAI Responses - const openaiResponsesApiKeyEl = document.getElementById('openaiResponsesApiKey'); - const openaiResponsesBaseUrlEl = document.getElementById('openaiResponsesBaseUrl'); - - if (openaiResponsesApiKeyEl) openaiResponsesApiKeyEl.value = data.OPENAI_API_KEY || ''; - if (openaiResponsesBaseUrlEl) openaiResponsesBaseUrlEl.value = data.OPENAI_BASE_URL || 'https://api.openai.com/v1'; // 高级配置参数 const systemPromptFilePathEl = document.getElementById('systemPromptFilePath'); @@ -114,34 +90,6 @@ async function loadConfiguration() { providerFallbackChainEl.value = ''; } } - - // 触发提供商配置显示 - handleProviderChange(); - - // 根据Gemini凭据类型设置显示 - const geminiCredsType = data.GEMINI_OAUTH_CREDS_BASE64 ? 'base64' : 'file'; - const geminiRadio = document.querySelector(`input[name="geminiCredsType"][value="${geminiCredsType}"]`); - if (geminiRadio) { - geminiRadio.checked = true; - handleGeminiCredsTypeChange({ target: geminiRadio }); - } - - // 根据Kiro凭据类型设置显示 - const kiroCredsType = data.KIRO_OAUTH_CREDS_BASE64 ? 'base64' : 'file'; - const kiroRadio = document.querySelector(`input[name="kiroCredsType"][value="${kiroCredsType}"]`); - if (kiroRadio) { - kiroRadio.checked = true; - handleKiroCredsTypeChange({ target: kiroRadio }); - } - - // 检查并设置提供商池菜单显示状态 - // const providerPoolsFilePath = data.PROVIDER_POOLS_FILE_PATH; - // const providersMenuItem = document.querySelector('.nav-item[data-section="providers"]'); - // if (providerPoolsFilePath && providerPoolsFilePath.trim() !== '') { - // if (providersMenuItem) providersMenuItem.style.display = 'flex'; - // } else { - // if (providersMenuItem) providersMenuItem.style.display = 'none'; - // } } catch (error) { console.error('Failed to load configuration:', error); @@ -152,76 +100,31 @@ async function loadConfiguration() { * 保存配置 */ async function saveConfiguration() { + const modelProviderEl = document.getElementById('modelProvider'); + let selectedProviders = []; + if (modelProviderEl) { + // 从复选框中获取选中的提供商 + selectedProviders = Array.from(modelProviderEl.querySelectorAll('input[type="checkbox"]:checked')) + .map(cb => cb.value); + } + + // 校验:必须至少勾选一个 + if (selectedProviders.length === 0) { + showToast(t('common.error'), t('config.modelProviderRequired'), 'error'); + return; + } + const config = { REQUIRED_API_KEY: document.getElementById('apiKey')?.value || '', HOST: document.getElementById('host')?.value || '127.0.0.1', SERVER_PORT: parseInt(document.getElementById('port')?.value || 3000), - MODEL_PROVIDER: document.getElementById('modelProvider')?.value || 'gemini-cli-oauth', + MODEL_PROVIDER: selectedProviders.length > 0 ? selectedProviders.join(',') : 'gemini-cli-oauth', systemPrompt: document.getElementById('systemPrompt')?.value || '', }; // 获取后台登录密码(如果有输入) const adminPassword = document.getElementById('adminPassword')?.value || ''; - // 根据不同提供商保存不同的配置 - const provider = document.getElementById('modelProvider')?.value; - - switch (provider) { - case 'gemini-cli-oauth': - config.PROJECT_ID = document.getElementById('projectId')?.value || ''; - const geminiCredsType = document.querySelector('input[name="geminiCredsType"]:checked')?.value; - if (geminiCredsType === 'base64') { - config.GEMINI_OAUTH_CREDS_BASE64 = document.getElementById('geminiOauthCredsBase64')?.value || ''; - config.GEMINI_OAUTH_CREDS_FILE_PATH = null; - } else { - config.GEMINI_OAUTH_CREDS_BASE64 = null; - config.GEMINI_OAUTH_CREDS_FILE_PATH = document.getElementById('geminiOauthCredsFilePath')?.value || ''; - } - config.GEMINI_BASE_URL = document.getElementById('geminiBaseUrl')?.value || null; - break; - - case 'gemini-antigravity': - config.ANTIGRAVITY_BASE_URL_DAILY = document.getElementById('antigravityBaseUrlDaily')?.value || null; - config.ANTIGRAVITY_BASE_URL_AUTOPUSH = document.getElementById('antigravityBaseUrlAutopush')?.value || null; - config.ANTIGRAVITY_OAUTH_CREDS_FILE_PATH = document.getElementById('antigravityOauthCredsFilePath')?.value || ''; - break; - - case 'openai-custom': - config.OPENAI_API_KEY = document.getElementById('openaiApiKey')?.value || ''; - config.OPENAI_BASE_URL = document.getElementById('openaiBaseUrl')?.value || ''; - break; - - case 'claude-custom': - config.CLAUDE_API_KEY = document.getElementById('claudeApiKey')?.value || ''; - config.CLAUDE_BASE_URL = document.getElementById('claudeBaseUrl')?.value || ''; - break; - - case 'claude-kiro-oauth': - const kiroCredsType = document.querySelector('input[name="kiroCredsType"]:checked')?.value; - if (kiroCredsType === 'base64') { - config.KIRO_OAUTH_CREDS_BASE64 = document.getElementById('kiroOauthCredsBase64')?.value || ''; - config.KIRO_OAUTH_CREDS_FILE_PATH = null; - } else { - config.KIRO_OAUTH_CREDS_BASE64 = null; - config.KIRO_OAUTH_CREDS_FILE_PATH = document.getElementById('kiroOauthCredsFilePath')?.value || ''; - } - config.KIRO_BASE_URL = document.getElementById('kiroBaseUrl')?.value || null; - config.KIRO_REFRESH_URL = document.getElementById('kiroRefreshUrl')?.value || null; - config.KIRO_REFRESH_IDC_URL = document.getElementById('kiroRefreshIdcUrl')?.value || null; - break; - - case 'openai-qwen-oauth': - config.QWEN_OAUTH_CREDS_FILE_PATH = document.getElementById('qwenOauthCredsFilePath')?.value || ''; - config.QWEN_BASE_URL = document.getElementById('qwenBaseUrl')?.value || null; - config.QWEN_OAUTH_BASE_URL = document.getElementById('qwenOauthBaseUrl')?.value || null; - break; - - case 'openaiResponses-custom': - config.OPENAI_API_KEY = document.getElementById('openaiResponsesApiKey')?.value || ''; - config.OPENAI_BASE_URL = document.getElementById('openaiResponsesBaseUrl')?.value || ''; - break; - } - // 保存高级配置参数 config.SYSTEM_PROMPT_FILE_PATH = document.getElementById('systemPromptFilePath')?.value || 'configs/input_system_prompt.txt'; config.SYSTEM_PROMPT_MODE = document.getElementById('systemPromptMode')?.value || 'append'; diff --git a/static/app/i18n.js b/static/app/i18n.js index e7e2793..7b8861d 100644 --- a/static/app/i18n.js +++ b/static/app/i18n.js @@ -136,6 +136,8 @@ const translations = { 'config.host': '监听地址', 'config.port': '端口', 'config.modelProvider': '模型提供商', + 'config.modelProviderHelp': '勾选启动时初始化的模型提供商 (必须至少勾选一个)', + 'config.modelProviderRequired': '必须至少勾选一个模型提供商', 'config.optional': '(选填)', 'config.gemini.baseUrl': 'Gemini Base URL', 'config.gemini.baseUrlPlaceholder': 'https://cloudcode-pa.googleapis.com', @@ -196,7 +198,7 @@ const translations = { 'config.advanced.cronEnabled': '启用OAuth令牌自动刷新(需重启服务)', 'config.advanced.poolFilePath': '提供商池配置文件路径(不能为空)', 'config.advanced.poolFilePathPlaceholder': '默认: configs/provider_pools.json', - 'config.advanced.poolNote': '配置了提供商池后,默认使用提供商池的配置,提供商池配置失效降级到默认配置', + 'config.advanced.poolNote': '使用默认路径配置需添加一个空节点', 'config.advanced.maxErrorCount': '提供商最大错误次数', 'config.advanced.maxErrorCountPlaceholder': '默认: 3', 'config.advanced.maxErrorCountNote': '提供商连续错误达到此次数后将被标记为不健康,默认为 3 次', @@ -272,7 +274,7 @@ const translations = { // Providers 'providers.title': '提供商池管理', - 'providers.note': '配置了提供商池后,默认使用提供商池的配置,提供商池配置失效降级到默认配置', + 'providers.note': '使用默认路径配置需添加一个空节点', 'providers.activeConnections': '活动连接', 'providers.activeProviders': '活跃提供商', 'providers.healthyProviders': '健康提供商', @@ -361,6 +363,8 @@ const translations = { 'usage.card.freeTrial': '免费试用', 'usage.card.bonus': '奖励', 'usage.card.expires': '到期: {time}', + 'usage.group.expandAll': '展开所有卡片', + 'usage.group.collapseAll': '折叠所有卡片', // Logs 'logs.title': '实时日志', @@ -541,6 +545,8 @@ const translations = { 'config.host': 'Listen Address', 'config.port': 'Port', 'config.modelProvider': 'Model Provider', + 'config.modelProviderHelp': 'Check model providers to initialize on startup (must select at least one)', + 'config.modelProviderRequired': 'At least one model provider must be selected', 'config.optional': '(Optional)', 'config.gemini.baseUrl': 'Gemini Base URL', 'config.gemini.baseUrlPlaceholder': 'https://cloudcode-pa.googleapis.com', @@ -601,7 +607,7 @@ const translations = { 'config.advanced.cronEnabled': 'Enable OAuth Token Auto Refresh (requires restart)', 'config.advanced.poolFilePath': 'Provider Pool Config File Path (required)', 'config.advanced.poolFilePathPlaceholder': 'Default: configs/provider_pools.json', - 'config.advanced.poolNote': 'When provider pool is configured, it will be used by default. Falls back to default config if pool config fails', + 'config.advanced.poolNote': 'To use default path configuration, add an empty node', 'config.advanced.maxErrorCount': 'Provider Max Error Count', 'config.advanced.maxErrorCountPlaceholder': 'Default: 3', 'config.advanced.maxErrorCountNote': 'Provider will be marked as unhealthy after consecutive errors reach this count, default is 3', @@ -677,7 +683,7 @@ const translations = { // Providers 'providers.title': 'Provider Pool Management', - 'providers.note': 'When provider pool is configured, it will be used by default. Falls back to default config if pool config fails', + 'providers.note': 'To use default path configuration, add an empty node', 'providers.activeConnections': 'Active Connections', 'providers.activeProviders': 'Active Providers', 'providers.healthyProviders': 'Healthy Providers', @@ -766,6 +772,8 @@ const translations = { 'usage.card.freeTrial': 'Free Trial', 'usage.card.bonus': 'Bonus', 'usage.card.expires': 'Expires: {time}', + 'usage.group.expandAll': 'Expand All Cards', + 'usage.group.collapseAll': 'Collapse All Cards', // Logs 'logs.title': 'Real-time Logs', diff --git a/static/app/language-switcher.js b/static/app/language-switcher.js index baf3744..ec88059 100644 --- a/static/app/language-switcher.js +++ b/static/app/language-switcher.js @@ -33,8 +33,8 @@ export function initLanguageSwitcher() { const headerControls = document.querySelector('.header-controls'); if (headerControls) { const switcher = createLanguageSwitcher(); - // 插入到第一个位置 - headerControls.insertBefore(switcher, headerControls.firstChild); + // 追加到最后位置(最右边) + headerControls.appendChild(switcher); // 绑定事件 bindLanguageSwitcherEvents(); diff --git a/static/app/provider-manager.js b/static/app/provider-manager.js index bcf069e..d8ad44e 100644 --- a/static/app/provider-manager.js +++ b/static/app/provider-manager.js @@ -791,7 +791,7 @@ function showAuthModal(authUrl, authInfo) { const applyBtn = modal.querySelector('.apply-callback-btn'); // 处理回调 URL 的核心逻辑 - const processCallback = (urlStr) => { + const processCallback = (urlStr, isManualInput = false) => { try { // 尝试清理 URL(有些用户可能会复制多余的文字) const cleanUrlStr = urlStr.trim().match(/https?:\/\/[^\s]+/)?.[0] || urlStr.trim(); @@ -806,13 +806,50 @@ function showAuthModal(authUrl, authInfo) { showToast(t('common.info'), t('oauth.processing'), 'info'); - // 优先在子窗口中跳转(如果没关) - if (authWindow && !authWindow.closed) { - authWindow.location.href = localUrl.href; + // 如果是手动输入,直接通过 fetch 请求处理,然后关闭子窗口 + if (isManualInput) { + // 关闭子窗口 + if (authWindow && !authWindow.closed) { + authWindow.close(); + } + // 通过服务端API处理手动输入的回调URL + window.apiClient.post('/oauth/manual-callback', { + provider: authInfo.provider, + callbackUrl: localUrl.href, + authMethod: authInfo.authMethod + }) + .then(response => { + if (response.success) { + console.log('OAuth 回调处理成功'); + showToast(t('common.success'), t('oauth.success.msg'), 'success'); + } else { + console.error('OAuth 回调处理失败:', response.error); + showToast(t('common.error'), response.error || t('oauth.error.process'), 'error'); + } + }) + .catch(err => { + console.error('OAuth 回调请求失败:', err); + showToast(t('common.error'), t('oauth.error.process'), 'error'); + }); } else { - // 备选方案:通过隐藏 iframe 或者是 fetch - const img = new Image(); - img.src = localUrl.href; + // 自动监听模式:优先在子窗口中跳转(如果没关) + if (authWindow && !authWindow.closed) { + authWindow.location.href = localUrl.href; + } else { + // 备选方案:通过 fetch 请求 + // 通过 fetch 请求本地服务器处理回调 + fetch(localUrl.href) + .then(response => { + if (response.ok) { + console.log('OAuth 回调处理成功'); + } else { + console.error('OAuth 回调处理失败:', response.status); + } + }) + .catch(err => { + console.error('OAuth 回调请求失败:', err); + }); + } } } else { @@ -825,7 +862,7 @@ function showAuthModal(authUrl, authInfo) { }; applyBtn.addEventListener('click', () => { - processCallback(manualInput.value); + processCallback(manualInput.value, true); }); // 启动定时器轮询子窗口 URL diff --git a/static/app/styles.css b/static/app/styles.css index a6fef34..0e2615c 100644 --- a/static/app/styles.css +++ b/static/app/styles.css @@ -1,4 +1,4 @@ -/* CSS变量 */ +/* CSS变量 - 亮色主题(默认) */ :root { --primary-color: #059669; --secondary-color: #10b981; @@ -15,6 +15,40 @@ --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1); --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1); --transition: all 0.3s ease; + + /* 代码块和日志区域 */ + --code-bg: #1e1e1e; + --code-text: #d4d4d4; + + /* 主题切换按钮 */ + --theme-toggle-bg: var(--bg-tertiary); + --theme-toggle-icon: var(--text-secondary); +} + +/* CSS变量 - 暗黑主题 */ +[data-theme="dark"] { + --primary-color: #10b981; + --secondary-color: #34d399; + --success-color: #34d399; + --danger-color: #f87171; + --warning-color: #fbbf24; + --bg-primary: #1f2937; + --bg-secondary: #111827; + --bg-tertiary: #374151; + --text-primary: #f9fafb; + --text-secondary: #9ca3af; + --border-color: #4b5563; + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5); + + /* 代码块和日志区域 */ + --code-bg: #0d1117; + --code-text: #e6edf3; + + /* 主题切换按钮 */ + --theme-toggle-bg: var(--bg-tertiary); + --theme-toggle-icon: #fbbf24; } /* 基础样式 */ @@ -536,6 +570,47 @@ textarea.form-control { cursor: pointer; } +/* 复选框列表样式 */ +.provider-checklist { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 10px; + padding: 15px; + background: var(--bg-tertiary); + border-radius: 8px; + border: 1px solid var(--border-color); + max-height: 300px; + overflow-y: auto; +} + +.checkbox-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 6px; + cursor: pointer; + transition: var(--transition); +} + +.checkbox-item:hover { + background: var(--bg-secondary); + border-color: var(--primary-color); + box-shadow: var(--shadow-sm); +} + +.checkbox-item input[type="checkbox"] { + margin: 0; +} + +.checkbox-item span { + font-size: 0.875rem; + color: var(--text-primary); + font-weight: 500; +} + /* 响应式调整 */ @media (max-width: 768px) { .config-row { @@ -2174,7 +2249,7 @@ input:checked + .toggle-slider:before { } .config-item-manager.expanded:hover { - background: #e5e7eb; + background: var(--bg-primary); } /* 响应式设计 */ @@ -3882,9 +3957,9 @@ input:checked + .toggle-slider:before { .usage-group-header { display: flex; align-items: center; + justify-content: space-between; padding: 0.75rem 1rem; background: var(--bg-secondary); - cursor: pointer; user-select: none; transition: var(--transition); } @@ -3898,6 +3973,46 @@ input:checked + .toggle-slider:before { align-items: center; gap: 0.75rem; flex: 1; + cursor: pointer; +} + +.usage-group-actions { + display: flex; + align-items: center; + gap: 0.5rem; + margin-left: 1rem; +} + +.btn-toggle-cards { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + background: var(--bg-primary); + color: var(--text-secondary); + border: 1px solid var(--border-color); + border-radius: 0.375rem; + cursor: pointer; + font-size: 0.875rem; + transition: var(--transition); +} + +.btn-toggle-cards:hover { + background: var(--primary-color); + color: white; + border-color: var(--primary-color); + transform: translateY(-1px); + box-shadow: var(--shadow-sm); +} + +.btn-toggle-cards:active { + transform: translateY(0); +} + +.btn-toggle-cards i { + font-size: 0.75rem; } .usage-group-title .toggle-icon { @@ -3999,7 +4114,6 @@ input:checked + .toggle-slider:before { } .usage-instance-card.success { - border-left: 3px solid var(--success-color); } .usage-instance-header { @@ -4202,8 +4316,7 @@ input:checked + .toggle-slider:before { /* 总用量样式 */ .total-usage { background: linear-gradient(135deg, var(--bg-primary) 0%, var(--bg-secondary) 100%); - border: 1px solid var(--primary-color); - border-left: 3px solid var(--primary-color); + border: 1px solid var(--border-color); } .total-usage-header { @@ -4505,6 +4618,121 @@ input:checked + .toggle-slider:before { gap: 0.375rem; } +/* 用量卡片折叠/展开样式 */ + +/* 折叠摘要 - 两行布局 */ +.usage-card-collapsed-summary { + display: flex; + flex-direction: column; + padding: 0.5rem 0.75rem; + cursor: pointer; + user-select: none; + transition: var(--transition); + background: linear-gradient(to right, var(--bg-tertiary), var(--bg-secondary)); + border-bottom: 1px solid var(--border-color); + gap: 0.375rem; +} + +.usage-card-collapsed-summary:hover { + background: linear-gradient(to right, var(--bg-secondary), var(--bg-tertiary)); +} + +/* 折叠摘要行 */ +.collapsed-summary-row { + display: flex; + align-items: center; + gap: 0.5rem; +} + +/* 第一行:名称行 */ +.collapsed-summary-name-row { + gap: 0.5rem; +} + +.collapsed-summary-name-row .provider-icon-small { + font-size: 0.75rem; + color: var(--primary-color); +} + +.collapsed-summary-name-row .collapsed-name { + font-size: 0.8rem; + font-weight: 600; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + min-width: 0; +} + +/* 第二行:用量行 */ +.collapsed-summary-usage-row { + padding-left: 1.25rem; /* 与名称对齐,跳过图标 */ + gap: 0.5rem; +} + +.collapsed-progress-bar { + width: 80px; + height: 0.375rem; + background: var(--bg-tertiary); + border-radius: 9999px; + overflow: hidden; + flex-shrink: 0; +} + +.collapsed-progress-bar .progress-fill { + height: 100%; + border-radius: 9999px; + transition: width 0.3s ease; +} + +.collapsed-progress-bar.normal .progress-fill { background: var(--success-color); } +.collapsed-progress-bar.warning .progress-fill { background: var(--warning-color); } +.collapsed-progress-bar.danger .progress-fill { background: var(--danger-color); } + +.collapsed-percent { + font-size: 0.7rem; + font-weight: 600; + color: var(--text-secondary); + min-width: 36px; +} + +.collapsed-usage-text { + font-size: 0.65rem; + color: var(--text-tertiary); +} + +.collapsed-error { + font-size: 0.7rem; + color: var(--danger-color); + font-weight: 500; +} + +.usage-toggle-icon { + font-size: 0.6rem; + color: var(--text-secondary); + transition: transform 0.3s ease; + flex-shrink: 0; +} + +.usage-instance-card:not(.collapsed) .usage-toggle-icon { + transform: rotate(90deg); +} + +/* 展开内容区域 */ +.usage-card-expanded-content { + display: block; +} + +.usage-instance-card.collapsed .usage-card-expanded-content { + display: none; +} + +/* 折叠状态下隐藏展开内容的边框 */ +.usage-instance-card.collapsed .usage-card-collapsed-summary { + border-bottom: none; +} + /* 用量卡片响应式调整 */ @media (max-width: 768px) { .usage-instance-header { @@ -4766,3 +4994,525 @@ input:checked + .toggle-slider:before { padding: 0.75rem; } } + +/* ==================== 主题切换按钮样式 ==================== */ +.theme-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + padding: 0; + background: var(--theme-toggle-bg); + color: var(--theme-toggle-icon); + border: 1px solid var(--border-color); + border-radius: 50%; + cursor: pointer; + font-size: 1.125rem; + transition: var(--transition); + position: relative; + overflow: hidden; +} + +.theme-toggle:hover { + background: var(--bg-secondary); + border-color: var(--primary-color); + transform: translateY(-2px); + box-shadow: var(--shadow-md); +} + +.theme-toggle:active { + transform: translateY(0); +} + +.theme-toggle i { + transition: transform 0.3s ease, opacity 0.3s ease; +} + +.theme-toggle .fa-sun { + display: none; +} + +.theme-toggle .fa-moon { + display: inline-block; +} + +[data-theme="dark"] .theme-toggle .fa-sun { + display: inline-block; +} + +[data-theme="dark"] .theme-toggle .fa-moon { + display: none; +} + +/* 暗黑主题下的特殊样式调整 */ + +/* 日志容器 */ +[data-theme="dark"] .logs-container { + background: var(--code-bg); + color: var(--code-text); +} + +/* 代码块 */ +[data-theme="dark"] .usage-example pre { + background: var(--code-bg); + color: var(--code-text); +} + +[data-theme="dark"] .config-content-display { + background: var(--code-bg); + color: var(--code-text); +} + +/* 高亮说明样式 - 暗黑主题 */ +[data-theme="dark"] .highlight-note { + background: linear-gradient(135deg, #78350f 0%, #92400e 100%); + border-color: #b45309; + color: #fef3c7; +} + +[data-theme="dark"] .highlight-note i { + color: #fbbf24; +} + +/* 提供商徽章 - 暗黑主题 */ +[data-theme="dark"] .provider-badge.official { + background: #1e3a5f; + color: #93c5fd; +} + +[data-theme="dark"] .provider-badge.oauth { + background: #064e3b; + color: #6ee7b7; +} + +[data-theme="dark"] .provider-badge.responses { + background: #78350f; + color: #fde68a; +} + +/* 状态徽章 - 暗黑主题 */ +[data-theme="dark"] .status-healthy { + background: #064e3b; + color: #6ee7b7; +} + +[data-theme="dark"] .status-unhealthy { + background: #7f1d1d; + color: #fca5a5; +} + +[data-theme="dark"] .status-used { + background: #064e3b; + color: #6ee7b7; +} + +[data-theme="dark"] .status-unused { + background: #78350f; + color: #fde68a; +} + +[data-theme="dark"] .status-invalid { + background: #7f1d1d; + color: #fca5a5; +} + +/* 更新徽章 - 暗黑主题 */ +[data-theme="dark"] .update-badge { + background: #78350f; + color: #fde68a; + border-color: #b45309; +} + +/* 模态框 - 暗黑主题 */ +[data-theme="dark"] .provider-modal-content { + background: var(--bg-primary); +} + +[data-theme="dark"] .provider-modal-header { + background: linear-gradient(135deg, var(--bg-secondary) 0%, var(--bg-tertiary) 100%); +} + +[data-theme="dark"] .provider-modal-header h3 { + color: var(--text-primary); +} + +[data-theme="dark"] .provider-summary { + background: linear-gradient(135deg, var(--bg-secondary) 0%, var(--bg-primary) 100%); + border-color: var(--border-color); +} + +[data-theme="dark"] .add-provider-form { + background: linear-gradient(135deg, var(--bg-secondary) 0%, var(--bg-primary) 100%); + border-color: var(--border-color); +} + +[data-theme="dark"] .provider-item-detail { + border-color: var(--border-color); +} + +[data-theme="dark"] .provider-item-header { + background: linear-gradient(135deg, var(--bg-secondary) 0%, var(--bg-primary) 100%); +} + +[data-theme="dark"] .provider-item-header:hover { + background: linear-gradient(135deg, var(--bg-tertiary) 0%, var(--bg-secondary) 100%); +} + +/* 健康状态高亮 - 暗黑主题 */ +[data-theme="dark"] .provider-item-detail.unhealthy { + border-color: var(--warning-color); + background: linear-gradient(135deg, #78350f 0%, var(--bg-primary) 100%); +} + +[data-theme="dark"] .provider-item-detail.unhealthy .provider-item-header { + background: linear-gradient(135deg, #78350f 0%, var(--bg-primary) 100%); +} + +/* 错误信息 - 暗黑主题 */ +[data-theme="dark"] .provider-error-info { + background: linear-gradient(135deg, #7f1d1d 0%, #991b1b 100%); + border-color: #dc2626; +} + +[data-theme="dark"] .provider-error-info i { + color: #fca5a5; +} + +[data-theme="dark"] .provider-error-info .error-label { + color: #fca5a5; +} + +[data-theme="dark"] .provider-error-info .error-message { + color: #fecaca; +} + +/* 路由提示 - 暗黑主题 */ +[data-theme="dark"] .routing-tips { + background: var(--bg-secondary); + border-left-color: var(--primary-color); +} + +[data-theme="dark"] .routing-tips code { + background: var(--bg-tertiary); + color: var(--primary-color); +} + +/* 端点路径 - 暗黑主题 */ +[data-theme="dark"] .endpoint-path { + background: var(--bg-tertiary); + border-color: var(--border-color); + color: var(--text-primary); +} + +/* 删除确认模态框 - 暗黑主题 */ +[data-theme="dark"] .delete-modal-content { + background: var(--bg-primary); +} + +[data-theme="dark"] .delete-confirm-modal.used .delete-modal-header { + background: linear-gradient(135deg, #7f1d1d 0%, var(--bg-primary) 100%); + border-bottom-color: #dc2626; +} + +[data-theme="dark"] .delete-confirm-modal.unused .delete-modal-header { + background: linear-gradient(135deg, #78350f 0%, var(--bg-primary) 100%); + border-bottom-color: #b45309; +} + +[data-theme="dark"] .delete-warning.warning-used { + background: linear-gradient(135deg, #7f1d1d 0%, var(--bg-primary) 100%); + border-color: #dc2626; + color: #fca5a5; +} + +[data-theme="dark"] .delete-warning.warning-unused { + background: linear-gradient(135deg, #78350f 0%, var(--bg-primary) 100%); + border-color: #b45309; + color: #fde68a; +} + +[data-theme="dark"] .usage-alert { + background: linear-gradient(135deg, #7f1d1d 0%, #991b1b 100%); + border-color: #dc2626; +} + +[data-theme="dark"] .alert-content h5 { + color: #fca5a5; +} + +[data-theme="dark"] .alert-content p, +[data-theme="dark"] .alert-content ul { + color: #fecaca; +} + +/* 配置信息 - 暗黑主题 */ +[data-theme="dark"] .config-info { + background: var(--bg-secondary); + border-color: var(--border-color); +} + +[data-theme="dark"] .config-info-item { + border-bottom-color: var(--border-color); +} + +[data-theme="dark"] .config-info-item .info-value.status-used { + background: #064e3b; + color: #6ee7b7; +} + +[data-theme="dark"] .config-info-item .info-value.status-unused { + background: #78350f; + color: #fde68a; +} + +/* 授权模态框 - 暗黑主题 */ +[data-theme="dark"] .modal-content { + background: var(--bg-primary); +} + +[data-theme="dark"] .modal-header { + background: linear-gradient(135deg, var(--bg-secondary) 0%, var(--bg-primary) 100%); +} + +[data-theme="dark"] .modal-footer { + background: linear-gradient(135deg, var(--bg-secondary) 0%, var(--bg-primary) 100%); +} + +[data-theme="dark"] .auth-instructions { + background: var(--bg-secondary); + border-color: var(--border-color); + border-left-color: var(--primary-color); +} + +[data-theme="dark"] .auth-note { + background: #78350f; + border-color: #b45309; + color: #fde68a; +} + +[data-theme="dark"] .auth-url-input { + background: var(--bg-tertiary); + border-color: var(--border-color); + color: var(--text-primary); +} + +/* 重启提示模态框 - 暗黑主题 */ +[data-theme="dark"] .restart-required-modal .restart-modal-content { + border-color: var(--primary-color); +} + +[data-theme="dark"] .restart-notice { + background: linear-gradient(135deg, #064e3b 0%, #065f46 100%); + border-color: var(--secondary-color); +} + +[data-theme="dark"] .restart-notice p { + color: #6ee7b7; +} + +[data-theme="dark"] .restart-instructions { + background: var(--bg-secondary); + border-color: var(--border-color); +} + +/* 用量卡片 - 暗黑主题 */ +[data-theme="dark"] .usage-instance-card.error { + border-color: #dc2626; +} + +[data-theme="dark"] .usage-instance-header { + background: linear-gradient(to right, var(--bg-tertiary), var(--bg-secondary)); +} + +[data-theme="dark"] .usage-error-message { + background: #7f1d1d; + color: #fca5a5; +} + +[data-theme="dark"] .usage-section { + background: var(--bg-secondary); + border-color: var(--border-color); +} + +[data-theme="dark"] .total-usage { + background: linear-gradient(135deg, var(--bg-primary) 0%, var(--bg-secondary) 100%); + border-color: var(--primary-color); +} + +[data-theme="dark"] .reset-info-compact { + background: var(--bg-secondary); + border-color: var(--border-color); +} + +[data-theme="dark"] .breakdown-item-compact { + background: var(--bg-primary); + border-color: var(--border-color); +} + +[data-theme="dark"] .extra-usage-info.free-trial { + background: #1e3a5f; + color: #93c5fd; + border-color: #3b82f6; +} + +[data-theme="dark"] .extra-usage-info.bonus { + background: #78350f; + color: #fde68a; + border-color: #b45309; +} + +/* 分页控件 - 暗黑主题 */ +[data-theme="dark"] .pagination-container { + background: var(--bg-tertiary); +} + +[data-theme="dark"] .page-btn { + background: var(--bg-primary); + border-color: var(--border-color); + color: var(--text-primary); +} + +[data-theme="dark"] .page-btn.nav-btn { + background: var(--bg-secondary); +} + +[data-theme="dark"] .page-jump-input { + background: var(--bg-primary); + border-color: var(--border-color); + color: var(--text-primary); +} + +/* 联系卡片 - 暗黑主题 */ +[data-theme="dark"] .contact-card { + background: var(--bg-primary); + border-color: var(--border-color); +} + +[data-theme="dark"] .contact-card:hover { + border-color: var(--primary-color); +} + +[data-theme="dark"] .qr-code { + border-color: var(--border-color); + background: white; +} + +[data-theme="dark"] .qr-code:hover { + border-color: var(--primary-color); +} + +/* 图片放大遮罩 - 暗黑主题 */ +[data-theme="dark"] .image-zoom-overlay { + background: rgba(0, 0, 0, 0.95); +} + +/* Toast 通知 - 暗黑主题 */ +[data-theme="dark"] .toast { + background: var(--bg-primary); + border-color: var(--border-color); +} + +/* 模型复选框 - 暗黑主题 */ +[data-theme="dark"] .model-checkbox-label { + background: var(--bg-primary); + border-color: var(--border-color); +} + +[data-theme="dark"] .model-checkbox-label:hover { + background: var(--bg-tertiary); + border-color: var(--text-secondary); +} + +[data-theme="dark"] .not-supported-models-container { + background: var(--bg-secondary); + border-color: var(--border-color); +} + +/* 表单控件 - 暗黑主题 */ +[data-theme="dark"] .form-control { + background: var(--bg-primary); + border-color: var(--border-color); + color: var(--text-primary); +} + +[data-theme="dark"] .form-control:focus { + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.2); +} + +[data-theme="dark"] select.form-control { + background: var(--bg-primary); + color: var(--text-primary); +} + +[data-theme="dark"] select.form-control option { + background: var(--bg-primary); + color: var(--text-primary); +} + +/* 按钮 - 暗黑主题调整 */ +[data-theme="dark"] .btn-secondary { + background: var(--bg-tertiary); + color: var(--text-primary); +} + +[data-theme="dark"] .btn-secondary:hover { + background: var(--border-color); +} + +[data-theme="dark"] .btn-outline { + background: var(--bg-tertiary); + color: var(--text-secondary); + border-color: var(--border-color); +} + +[data-theme="dark"] .btn-outline:hover { + background: var(--bg-secondary); + color: var(--primary-color); + border-color: var(--primary-color); +} + +/* 语言切换器 - 暗黑主题 */ +[data-theme="dark"] .language-btn { + background: transparent; + color: var(--text-secondary); + border-color: var(--border-color); +} + +[data-theme="dark"] .language-btn:hover { + background: var(--bg-tertiary); + color: var(--primary-color); + border-color: var(--primary-color); +} + +[data-theme="dark"] .language-dropdown { + background: var(--bg-primary); + border-color: var(--border-color); +} + +[data-theme="dark"] .language-option:hover { + background: var(--bg-secondary); +} + +/* 主题切换动画 */ +body { + transition: background-color 0.3s ease, color 0.3s ease; +} + +.header, +.sidebar, +.content, +.stat-card, +.config-panel, +.providers-container, +.routing-examples-panel, +.system-info-panel, +.upload-config-panel, +.usage-panel, +.logs-container, +.toast, +.modal-content, +.provider-modal-content { + transition: background-color 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease; +} diff --git a/static/app/theme-switcher.js b/static/app/theme-switcher.js new file mode 100644 index 0000000..70dc42a --- /dev/null +++ b/static/app/theme-switcher.js @@ -0,0 +1,118 @@ +/** + * 主题切换模块 + * 支持亮色/暗黑主题切换,并保存用户偏好到 localStorage + */ + +// 主题常量 +const THEME_KEY = 'theme'; +const THEME_LIGHT = 'light'; +const THEME_DARK = 'dark'; + +/** + * 获取当前主题 + * @returns {string} 当前主题 ('light' 或 'dark') + */ +export function getCurrentTheme() { + // 优先从 localStorage 获取 + const savedTheme = localStorage.getItem(THEME_KEY); + if (savedTheme) { + return savedTheme; + } + + // 检查系统偏好 + if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { + return THEME_DARK; + } + + return THEME_LIGHT; +} + +/** + * 设置主题 + * @param {string} theme - 主题名称 ('light' 或 'dark') + */ +export function setTheme(theme) { + const root = document.documentElement; + + if (theme === THEME_DARK) { + root.setAttribute('data-theme', THEME_DARK); + } else { + root.removeAttribute('data-theme'); + } + + // 保存到 localStorage + localStorage.setItem(THEME_KEY, theme); + + // 更新 meta theme-color + updateMetaThemeColor(theme); + + // 触发自定义事件 + window.dispatchEvent(new CustomEvent('themechange', { detail: { theme } })); +} + +/** + * 切换主题 + * @returns {string} 切换后的主题 + */ +export function toggleTheme() { + const currentTheme = getCurrentTheme(); + const newTheme = currentTheme === THEME_DARK ? THEME_LIGHT : THEME_DARK; + setTheme(newTheme); + return newTheme; +} + +/** + * 更新 meta theme-color + * @param {string} theme - 主题名称 + */ +function updateMetaThemeColor(theme) { + const metaThemeColor = document.querySelector('meta[name="theme-color"]'); + if (metaThemeColor) { + // 暗黑主题使用深色,亮色主题使用主色调 + metaThemeColor.setAttribute('content', theme === THEME_DARK ? '#1f2937' : '#059669'); + } +} + +/** + * 初始化主题切换器 + * @param {string} [toggleButtonId='themeToggleBtn'] - 切换按钮的 ID + */ +export function initThemeSwitcher(toggleButtonId = 'themeToggleBtn') { + // 应用保存的主题或系统偏好 + const savedTheme = getCurrentTheme(); + setTheme(savedTheme); + + // 绑定切换按钮事件 + const toggleBtn = document.getElementById(toggleButtonId); + if (toggleBtn) { + toggleBtn.addEventListener('click', () => { + const newTheme = toggleTheme(); + console.log(`主题已切换为: ${newTheme === THEME_DARK ? '暗黑模式' : '亮色模式'}`); + }); + } + + // 监听系统主题变化 + if (window.matchMedia) { + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + mediaQuery.addEventListener('change', (e) => { + // 只有在用户没有手动设置主题时才跟随系统 + const savedTheme = localStorage.getItem(THEME_KEY); + if (!savedTheme) { + setTheme(e.matches ? THEME_DARK : THEME_LIGHT); + } + }); + } + + console.log(`主题切换器已初始化,当前主题: ${savedTheme === THEME_DARK ? '暗黑模式' : '亮色模式'}`); +} + +/** + * 检查当前是否为暗黑主题 + * @returns {boolean} + */ +export function isDarkTheme() { + return getCurrentTheme() === THEME_DARK; +} + +// 导出常量 +export { THEME_LIGHT, THEME_DARK }; \ No newline at end of file diff --git a/static/app/usage-manager.js b/static/app/usage-manager.js index 195a58b..6bd550c 100644 --- a/static/app/usage-manager.js +++ b/static/app/usage-manager.js @@ -228,15 +228,49 @@ function createProviderGroup(providerType, instances) { ${t('usage.group.instances', { count: instanceCount })} ${t('usage.group.success', { count: successCount, total: instanceCount })} +
+ +
`; - // 点击头部切换折叠状态 - header.addEventListener('click', () => { + // 点击头部切换分组折叠状态 + const titleDiv = header.querySelector('.usage-group-title'); + titleDiv.addEventListener('click', () => { groupContainer.classList.toggle('collapsed'); }); groupContainer.appendChild(header); + // 展开/折叠所有卡片按钮事件 + const toggleCardsBtn = header.querySelector('.btn-toggle-cards'); + toggleCardsBtn.addEventListener('click', (e) => { + e.stopPropagation(); // 阻止事件冒泡到分组头部 + + const cards = groupContainer.querySelectorAll('.usage-instance-card'); + const allCollapsed = Array.from(cards).every(card => card.classList.contains('collapsed')); + + // 如果全部折叠,则全部展开;否则全部折叠 + cards.forEach(card => { + if (allCollapsed) { + card.classList.remove('collapsed'); + } else { + card.classList.add('collapsed'); + } + }); + + // 更新按钮图标和提示文本 + const icon = toggleCardsBtn.querySelector('i'); + if (allCollapsed) { + icon.className = 'fas fa-compress-alt'; + toggleCardsBtn.title = t('usage.group.collapseAll'); + } else { + icon.className = 'fas fa-expand-alt'; + toggleCardsBtn.title = t('usage.group.expandAll'); + } + }); + // 分组内容(卡片网格) const content = document.createElement('div'); content.className = 'usage-group-content'; @@ -263,19 +297,59 @@ function createProviderGroup(providerType, instances) { */ function createInstanceUsageCard(instance, providerType) { const card = document.createElement('div'); - card.className = `usage-instance-card ${instance.success ? 'success' : 'error'}`; + card.className = `usage-instance-card ${instance.success ? 'success' : 'error'} collapsed`; const providerDisplayName = getProviderDisplayName(providerType); const providerIcon = getProviderIcon(providerType); - // 实例头部 - 整合用户信息 - const header = document.createElement('div'); - header.className = 'usage-instance-header'; + // 计算总用量(用于折叠摘要显示) + const totalUsage = instance.usage ? calculateTotalUsage(instance.usage.usageBreakdown) : { hasData: false, percent: 0 }; + const progressClass = totalUsage.percent >= 90 ? 'danger' : (totalUsage.percent >= 70 ? 'warning' : 'normal'); + + // 折叠摘要 - 两行显示 + const collapsedSummary = document.createElement('div'); + collapsedSummary.className = 'usage-card-collapsed-summary'; const statusIcon = instance.success ? '' : ''; + // 显示名称:优先自定义名称,其次 uuid + const displayName = instance.name || instance.uuid; + + collapsedSummary.innerHTML = ` +
+ + ${displayName} + ${statusIcon} +
+
+ ${totalUsage.hasData ? ` +
+
+
+ ${totalUsage.percent.toFixed(1)}% + ${formatNumber(totalUsage.used)} / ${formatNumber(totalUsage.limit)} + ` : (instance.error ? `${t('common.error')}` : '')} +
+ `; + + // 点击折叠摘要切换展开状态 + collapsedSummary.addEventListener('click', (e) => { + e.stopPropagation(); + card.classList.toggle('collapsed'); + }); + + card.appendChild(collapsedSummary); + + // 展开内容区域 + const expandedContent = document.createElement('div'); + expandedContent.className = 'usage-card-expanded-content'; + + // 实例头部 - 整合用户信息 + const header = document.createElement('div'); + header.className = 'usage-instance-header'; + const healthBadge = instance.isDisabled ? `${t('usage.card.status.disabled')}` : (instance.isHealthy @@ -310,7 +384,7 @@ function createInstanceUsageCard(instance, providerType) { ${userInfoHTML} `; - card.appendChild(header); + expandedContent.appendChild(header); // 实例内容 - 只显示用量和到期时间 const content = document.createElement('div'); @@ -327,7 +401,9 @@ function createInstanceUsageCard(instance, providerType) { content.appendChild(renderUsageDetails(instance.usage)); } - card.appendChild(content); + expandedContent.appendChild(content); + card.appendChild(expandedContent); + return card; } @@ -352,7 +428,10 @@ function renderUsageDetails(usage) { totalSection.innerHTML = `
- ${t('usage.card.totalUsage')} + + + ${t('usage.card.totalUsage')} + ${formatNumber(totalUsage.used)} / ${formatNumber(totalUsage.limit)}
@@ -360,6 +439,7 @@ function renderUsageDetails(usage) {
${totalUsage.percent.toFixed(2)}%
`; + container.appendChild(totalSection); } diff --git a/static/index.html b/static/index.html index bb2dfdb..c8448cd 100644 --- a/static/index.html +++ b/static/index.html @@ -20,10 +20,13 @@ 连接中... + - @@ -543,208 +546,38 @@
- - -
- - -
-
- - -
-
- - -
-
- -
- - -
-
-
- - -
- -
- - - - - - - - - - - - - - - - - -