feat: 新增主题切换功能并优化提供商池初始化

refactor: 重构配置管理移除冗余提供商配置
fix: 修复手动OAuth回调处理逻辑
style: 优化用量卡片UI增加折叠功能
perf: 提升服务启动时提供商池节点初始化效率
docs: 更新i18n翻译文本和配置说明
chore: 清理无用代码和配置文件
This commit is contained in:
hex2077 2026-01-03 18:02:16 +08:00
parent ed634050a9
commit f691380482
14 changed files with 1263 additions and 652 deletions

View file

@ -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"],

View file

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

View file

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

View file

@ -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,

View file

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

View file

@ -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',

View file

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

View file

@ -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',

View file

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

View file

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

View file

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

View file

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

View file

@ -228,15 +228,49 @@ function createProviderGroup(providerType, instances) {
<span class="instance-count" data-i18n="usage.group.instances" data-i18n-params='{"count":"${instanceCount}"}'>${t('usage.group.instances', { count: instanceCount })}</span>
<span class="success-count ${successCount === instanceCount ? 'all-success' : ''}" data-i18n="usage.group.success" data-i18n-params='{"count":"${successCount}","total":"${instanceCount}"}'>${t('usage.group.success', { count: successCount, total: instanceCount })}</span>
</div>
<div class="usage-group-actions">
<button class="btn-toggle-cards" title="${t('usage.group.expandAll')}">
<i class="fas fa-expand-alt"></i>
</button>
</div>
`;
// 点击头部切换折叠状态
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
? '<i class="fas fa-check-circle status-success"></i>'
: '<i class="fas fa-times-circle status-error"></i>';
// 显示名称:优先自定义名称,其次 uuid
const displayName = instance.name || instance.uuid;
collapsedSummary.innerHTML = `
<div class="collapsed-summary-row collapsed-summary-name-row">
<i class="fas fa-chevron-right usage-toggle-icon"></i>
<span class="collapsed-name" title="${displayName}">${displayName}</span>
${statusIcon}
</div>
<div class="collapsed-summary-row collapsed-summary-usage-row">
${totalUsage.hasData ? `
<div class="collapsed-progress-bar ${progressClass}">
<div class="progress-fill" style="width: ${totalUsage.percent}%"></div>
</div>
<span class="collapsed-percent">${totalUsage.percent.toFixed(1)}%</span>
<span class="collapsed-usage-text">${formatNumber(totalUsage.used)} / ${formatNumber(totalUsage.limit)}</span>
` : (instance.error ? `<span class="collapsed-error">${t('common.error')}</span>` : '')}
</div>
`;
// 点击折叠摘要切换展开状态
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
? `<span class="badge badge-disabled" data-i18n="usage.card.status.disabled">${t('usage.card.status.disabled')}</span>`
: (instance.isHealthy
@ -310,7 +384,7 @@ function createInstanceUsageCard(instance, providerType) {
</div>
${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 = `
<div class="total-usage-header">
<span class="total-label"><i class="fas fa-chart-pie"></i> <span data-i18n="usage.card.totalUsage">${t('usage.card.totalUsage')}</span></span>
<span class="total-label">
<i class="fas fa-chart-pie"></i>
<span data-i18n="usage.card.totalUsage">${t('usage.card.totalUsage')}</span>
</span>
<span class="total-value">${formatNumber(totalUsage.used)} / ${formatNumber(totalUsage.limit)}</span>
</div>
<div class="progress-bar ${progressClass}">
@ -360,6 +439,7 @@ function renderUsageDetails(usage) {
</div>
<div class="total-percent">${totalUsage.percent.toFixed(2)}%</div>
`;
container.appendChild(totalSection);
}

View file

@ -20,10 +20,13 @@
<span class="status-badge" id="serverStatus">
<i class="fas fa-circle"></i> <span class="status-text" data-i18n="header.status.connecting">连接中...</span>
</span>
<button id="themeToggleBtn" class="theme-toggle" aria-label="Toggle Theme" data-i18n-aria-label="header.themeToggle" title="切换主题" data-i18n-title="header.themeToggle">
<i class="fas fa-moon"></i>
<i class="fas fa-sun"></i>
</button>
<button id="logoutBtn" class="logout-btn" data-i18n="header.logout" title="Logout" data-i18n-title="header.logout">
<i class="fas fa-sign-out-alt"></i> <span data-i18n="header.logout">登出</span>
</button>
</span>
<button id="restartBtn" class="logout-btn" aria-label="Restart Service" data-i18n-aria-label="header.restart">
<i id="restartBtnIcon" class="fas fa-redo"></i> <span id="restartBtnText" class="btn-text" data-i18n="header.restart">重启</span>
</button>
@ -543,208 +546,38 @@
</div>
</div>
<div class="form-group">
<label for="modelProvider" data-i18n="config.modelProvider">模型提供商</label>
<select id="modelProvider" class="form-control">
<option value="gemini-cli-oauth">Gemini CLI OAuth</option>
<option value="gemini-antigravity">Gemini Antigravity</option>
<option value="openai-custom">OpenAI Custom</option>
<option value="claude-custom">Claude Custom</option>
<option value="claude-kiro-oauth">Claude Kiro OAuth</option>
<option value="openai-qwen-oauth">Qwen OAuth</option>
<option value="openaiResponses-custom">OpenAI Responses</option>
</select>
</div>
<!-- Gemini CLI OAuth 配置 -->
<div class="provider-config" data-provider="gemini-cli-oauth">
<div class="form-group">
<label for="geminiBaseUrl"><span data-i18n="config.gemini.baseUrl">Gemini Base URL</span> <span class="optional-tag" data-i18n="config.optional">(选填)</span></label>
<input type="text" id="geminiBaseUrl" class="form-control" data-i18n-placeholder="config.gemini.baseUrlPlaceholder" placeholder="https://cloudcode-pa.googleapis.com">
</div>
<div class="form-group">
<label for="projectId"><span data-i18n="config.gemini.projectId">项目ID</span> <span class="optional-tag" data-i18n="config.optional">(选填)</span></label>
<input type="text" id="projectId" class="form-control" data-i18n-placeholder="config.gemini.projectIdPlaceholder" placeholder="Google Cloud项目ID">
</div>
<div class="form-group">
<label data-i18n="config.gemini.oauthCreds">OAuth凭据</label>
<div class="radio-group">
<label class="radio-label">
<input type="radio" name="geminiCredsType" value="file" checked>
<span data-i18n="config.gemini.credsType.file">文件路径</span>
</label>
<label class="radio-label">
<input type="radio" name="geminiCredsType" value="base64">
<span data-i18n="config.gemini.credsType.base64">Base64编码</span>
</label>
</div>
</div>
<div class="form-group" id="geminiCredsBase64Group">
<label for="geminiOauthCredsBase64" data-i18n="config.gemini.credsBase64">OAuth凭据 (Base64)</label>
<textarea id="geminiOauthCredsBase64" class="form-control" rows="3" data-i18n-placeholder="config.gemini.credsBase64Placeholder" placeholder="请输入Base64编码的OAuth凭据"></textarea>
</div>
<div class="form-group" id="geminiCredsFileGroup" style="display: none;">
<label for="geminiOauthCredsFilePath" data-i18n="config.gemini.credsFilePath">OAuth凭据文件路径</label>
<div class="file-input-group">
<input type="text" id="geminiOauthCredsFilePath" class="form-control" data-i18n-placeholder="config.gemini.credsFilePathPlaceholder" placeholder="例如: ~/.gemini/oauth_creds.json">
<button type="button" class="btn btn-outline upload-btn" data-target="geminiOauthCredsFilePath" data-i18n-title="common.upload" title="上传文件" aria-label="上传文件" data-i18n-aria-label="common.upload">
<i class="fas fa-upload"></i>
</button>
<button type="button" class="btn btn-outline generate-creds-btn" data-target="geminiOauthCredsFilePath" data-provider="gemini-cli-oauth" data-i18n-title="common.generate" title="生成凭据文件">
<i class="fas fa-magic"></i>
</button>
</div>
</div>
</div>
<!-- Gemini Antigravity 配置 -->
<div class="provider-config" data-provider="gemini-antigravity" style="display: none;">
<div class="form-group">
<label for="antigravityBaseUrlDaily"><span data-i18n="config.antigravity.dailyUrl">Daily Base URL</span> <span class="optional-tag" data-i18n="config.optional">(选填)</span></label>
<input type="text" id="antigravityBaseUrlDaily" class="form-control" data-i18n-placeholder="config.antigravity.dailyUrlPlaceholder" placeholder="https://daily-cloudcode-pa.sandbox.googleapis.com">
</div>
<div class="form-group">
<label for="antigravityBaseUrlAutopush"><span data-i18n="config.antigravity.autopushUrl">Autopush Base URL</span> <span class="optional-tag" data-i18n="config.optional">(选填)</span></label>
<input type="text" id="antigravityBaseUrlAutopush" class="form-control" data-i18n-placeholder="config.antigravity.autopushUrlPlaceholder" placeholder="https://autopush-cloudcode-pa.sandbox.googleapis.com">
</div>
<div class="form-group">
<label for="antigravityOauthCredsFilePath" data-i18n="config.antigravity.credsFilePath">OAuth凭据文件路径</label>
<div class="file-input-group">
<input type="text" id="antigravityOauthCredsFilePath" class="form-control" data-i18n-placeholder="config.antigravity.credsFilePathPlaceholder" placeholder="例如: ~/.antigravity/oauth_creds.json">
<button type="button" class="btn btn-outline upload-btn" data-target="antigravityOauthCredsFilePath" data-i18n-title="common.upload" title="上传文件" aria-label="上传文件" data-i18n-aria-label="common.upload">
<i class="fas fa-upload"></i>
</button>
<button type="button" class="btn btn-outline generate-creds-btn" data-target="antigravityOauthCredsFilePath" data-provider="gemini-antigravity" data-i18n-title="common.generate" title="生成凭据文件">
<i class="fas fa-magic"></i>
</button>
</div>
<small class="form-text" data-i18n="config.antigravity.note">Antigravity 使用 Google OAuth 认证,需要提供凭据文件路径</small>
</div>
</div>
<!-- OpenAI Custom 配置 -->
<div class="provider-config" data-provider="openai-custom" style="display: none;">
<div class="form-group password-input-group">
<label for="openaiApiKey" data-i18n="config.openai.apiKey">OpenAI API Key</label>
<div class="password-input-wrapper">
<input type="password" id="openaiApiKey" class="form-control" data-i18n-placeholder="config.openai.apiKeyPlaceholder" placeholder="sk-...">
<button type="button" class="password-toggle" data-target="openaiApiKey">
<i class="fas fa-eye"></i>
</button>
</div>
</div>
<div class="form-group">
<label for="openaiBaseUrl" data-i18n="config.openai.baseUrl">OpenAI Base URL</label>
<input type="text" id="openaiBaseUrl" class="form-control" value="https://api.openai.com/v1" data-i18n-placeholder="config.openai.baseUrlPlaceholder" placeholder="例如: https://api.openai.com/v1">
</div>
</div>
<!-- Claude Custom 配置 -->
<div class="provider-config" data-provider="claude-custom" style="display: none;">
<div class="form-group password-input-group">
<label for="claudeApiKey" data-i18n="config.claude.apiKey">Claude API Key</label>
<div class="password-input-wrapper">
<input type="password" id="claudeApiKey" class="form-control" data-i18n-placeholder="config.claude.apiKeyPlaceholder" placeholder="sk-ant-...">
<button type="button" class="password-toggle" data-target="claudeApiKey">
<i class="fas fa-eye"></i>
</button>
</div>
</div>
<div class="form-group">
<label for="claudeBaseUrl" data-i18n="config.claude.baseUrl">Claude Base URL</label>
<input type="text" id="claudeBaseUrl" class="form-control" value="https://api.anthropic.com" data-i18n-placeholder="config.claude.baseUrlPlaceholder" placeholder="例如: https://api.anthropic.com">
</div>
</div>
<!-- Claude Kiro OAuth 配置 -->
<div class="provider-config" data-provider="claude-kiro-oauth" style="display: none;">
<div class="form-row">
<div class="form-group">
<label for="kiroBaseUrl"><span data-i18n="config.kiro.baseUrl">Base URL</span> <span class="optional-tag" data-i18n="config.optional">(选填)</span></label>
<input type="text" id="kiroBaseUrl" class="form-control" data-i18n-placeholder="config.kiro.baseUrlPlaceholder" placeholder="https://codewhisperer.{{region}}.amazonaws.com/generateAssistantResponse">
</div>
<div class="form-group">
<label for="kiroRefreshUrl"><span data-i18n="config.kiro.refreshUrl">Refresh URL</span> <span class="optional-tag" data-i18n="config.optional">(选填)</span></label>
<input type="text" id="kiroRefreshUrl" class="form-control" data-i18n-placeholder="config.kiro.refreshUrlPlaceholder" placeholder="https://prod.{{region}}.auth.desktop.kiro.dev/refreshToken">
</div>
<div class="form-group">
<label for="kiroRefreshIdcUrl"><span data-i18n="config.kiro.refreshIdcUrl">Refresh IDC URL</span> <span class="optional-tag" data-i18n="config.optional">(选填)</span></label>
<input type="text" id="kiroRefreshIdcUrl" class="form-control" data-i18n-placeholder="config.kiro.refreshIdcUrlPlaceholder" placeholder="https://oidc.{{region}}.amazonaws.com/token">
</div>
</div>
<div class="form-group">
<label data-i18n="config.gemini.oauthCreds">OAuth凭据</label>
<div class="radio-group">
<label class="radio-label">
<input type="radio" name="kiroCredsType" value="file" checked>
<span data-i18n="config.gemini.credsType.file">文件路径</span>
</label>
<label class="radio-label">
<input type="radio" name="kiroCredsType" value="base64">
<span data-i18n="config.gemini.credsType.base64">Base64编码</span>
</label>
</div>
</div>
<div class="form-group" id="kiroCredsBase64Group">
<label for="kiroOauthCredsBase64" data-i18n="config.gemini.credsBase64">OAuth凭据 (Base64)</label>
<textarea id="kiroOauthCredsBase64" class="form-control" rows="3" data-i18n="config.gemini.credsBase64Placeholder" placeholder="请输入Base64编码的OAuth凭据"></textarea>
</div>
<div class="form-group" id="kiroCredsFileGroup" style="display: none;">
<label for="kiroOauthCredsFilePath" data-i18n="config.kiro.credsFilePath">OAuth凭据文件路径</label>
<div class="file-input-group">
<input type="text" id="kiroOauthCredsFilePath" class="form-control" data-i18n-placeholder="config.kiro.credsFilePathPlaceholder" placeholder="例如: ~/.aws/sso/cache/kiro-auth-token.json">
<button type="button" class="btn btn-outline upload-btn" data-target="kiroOauthCredsFilePath" data-i18n-title="common.upload" title="上传文件" aria-label="上传文件" data-i18n-aria-label="common.upload">
<i class="fas fa-upload"></i>
</button>
<button type="button" class="btn btn-outline generate-creds-btn" data-target="kiroOauthCredsFilePath" data-provider="claude-kiro-oauth" data-i18n-title="common.generate" title="生成凭据文件">
<i class="fas fa-magic"></i>
</button>
</div>
<small class="form-text">
<i class="fas fa-info-circle"></i>
<span data-i18n="config.kiro.note">使用 AWS 登录方式时,请确保授权文件中包含 clientId 和 clientSecret 字段</span>
</small>
</div>
</div>
<!-- Qwen OAuth 配置 -->
<div class="provider-config" data-provider="openai-qwen-oauth" style="display: none;">
<div class="form-group">
<label for="qwenBaseUrl"><span data-i18n="config.qwen.baseUrl">Qwen Base URL</span> <span class="optional-tag" data-i18n="config.optional">(选填)</span></label>
<input type="text" id="qwenBaseUrl" class="form-control" data-i18n-placeholder="config.qwen.baseUrlPlaceholder" placeholder="https://portal.qwen.ai/v1">
</div>
<div class="form-group">
<label for="qwenOauthBaseUrl"><span data-i18n="config.qwen.oauthBaseUrl">OAuth Base URL</span> <span class="optional-tag" data-i18n="config.optional">(选填)</span></label>
<input type="text" id="qwenOauthBaseUrl" class="form-control" data-i18n-placeholder="config.qwen.oauthBaseUrlPlaceholder" placeholder="https://chat.qwen.ai">
</div>
<div class="form-group">
<label for="qwenOauthCredsFilePath" data-i18n="config.qwen.credsFilePath">OAuth凭据文件路径</label>
<div class="file-input-group">
<input type="text" id="qwenOauthCredsFilePath" class="form-control" data-i18n-placeholder="config.qwen.credsFilePathPlaceholder" placeholder="例如: ~/.qwen/oauth_creds.json">
<button type="button" class="btn btn-outline upload-btn" data-target="qwenOauthCredsFilePath" data-i18n-title="common.upload" title="上传文件" aria-label="上传文件" data-i18n-aria-label="common.upload">
<i class="fas fa-upload"></i>
</button>
<button type="button" class="btn btn-outline generate-creds-btn" data-target="qwenOauthCredsFilePath" data-provider="openai-qwen-oauth" data-i18n-title="common.generate" title="生成凭据文件">
<i class="fas fa-magic"></i>
</button>
</div>
</div>
</div>
<!-- OpenAI Responses 配置 -->
<div class="provider-config" data-provider="openaiResponses-custom" style="display: none;">
<div class="form-group password-input-group">
<label for="openaiResponsesApiKey" data-i18n="config.openai.apiKey">OpenAI API Key</label>
<div class="password-input-wrapper">
<input type="password" id="openaiResponsesApiKey" class="form-control" data-i18n-placeholder="config.openai.apiKeyPlaceholder" placeholder="sk-...">
<button type="button" class="password-toggle" data-target="openaiResponsesApiKey">
<i class="fas fa-eye"></i>
</button>
</div>
</div>
<div class="form-group">
<label for="openaiResponsesBaseUrl" data-i18n="config.openai.baseUrl">OpenAI Base URL</label>
<input type="text" id="openaiResponsesBaseUrl" class="form-control" value="https://api.openai.com/v1" data-i18n-placeholder="config.openai.baseUrlPlaceholder" placeholder="例如: https://api.openai.com/v1">
<label data-i18n="config.modelProvider">模型提供商 (可多选)</label>
<div id="modelProvider" class="provider-checklist">
<label class="checkbox-item">
<input type="checkbox" value="gemini-cli-oauth">
<span>Gemini CLI OAuth</span>
</label>
<label class="checkbox-item">
<input type="checkbox" value="gemini-antigravity">
<span>Gemini Antigravity</span>
</label>
<label class="checkbox-item">
<input type="checkbox" value="openai-custom">
<span>OpenAI Custom</span>
</label>
<label class="checkbox-item">
<input type="checkbox" value="claude-custom">
<span>Claude Custom</span>
</label>
<label class="checkbox-item">
<input type="checkbox" value="claude-kiro-oauth">
<span>Claude Kiro OAuth</span>
</label>
<label class="checkbox-item">
<input type="checkbox" value="openai-qwen-oauth">
<span>Qwen OAuth</span>
</label>
<label class="checkbox-item">
<input type="checkbox" value="openaiResponses-custom">
<span>OpenAI Responses</span>
</label>
</div>
<small class="form-text text-muted" data-i18n="config.modelProviderHelp">勾选启动时初始化的模型提供商 (必须至少勾选一个)</small>
</div>
<!-- 高级配置区域 -->
@ -808,7 +641,7 @@
<div class="form-group pool-section">
<label for="providerPoolsFilePath" data-i18n="config.advanced.poolFilePath">提供商池配置文件路径(不能为空)</label>
<input type="text" id="providerPoolsFilePath" class="form-control" value="" data-i18n-placeholder="config.advanced.poolFilePathPlaceholder" placeholder="默认: configs/provider_pools.json">
<small class="form-text" data-i18n="config.advanced.poolNote">配置了提供商池后,默认使用提供商池的配置,提供商池配置失效降级到默认配置</small>
<small class="form-text" data-i18n="config.advanced.poolNote">使用默认路径配置需添加一个空节点</small>
</div>
<div class="form-group pool-section">
@ -923,7 +756,7 @@
<div class="pool-description">
<div class="highlight-note">
<i class="fas fa-info-circle"></i>
<span data-i18n="providers.note">配置了提供商池后,默认使用提供商池的配置,提供商池配置失效降级到默认配置</span>
<span data-i18n="providers.note">使用默认路径配置需添加一个空节点</span>
</div>
</div>
<!-- Provider Pool Stats -->
@ -1019,16 +852,21 @@
<!-- Scripts -->
<script type="module" src="app/i18n.js"></script>
<script type="module" src="app/language-switcher.js"></script>
<script type="module" src="app/theme-switcher.js"></script>
<script type="module" src="app/auth.js"></script>
<script type="module">
// 导入多语言和认证函数
// 导入多语言、主题切换和认证函数
import { initI18n, t } from './app/i18n.js';
import { initLanguageSwitcher } from './app/language-switcher.js';
import { initThemeSwitcher } from './app/theme-switcher.js';
import { initAuth, logout } from './app/auth.js';
// 初始化多语言
initI18n();
// 初始化主题切换器(尽早初始化以避免闪烁)
initThemeSwitcher();
// 页面加载时检查登录状态
(async function() {
const isAuthenticated = await initAuth();