diff --git a/src/request-handler.js b/src/request-handler.js index dfa48c8..6106f8a 100644 --- a/src/request-handler.js +++ b/src/request-handler.js @@ -58,7 +58,7 @@ export function createRequestHandler(config, providerPoolManager) { // 检查是否是插件静态文件 const pluginManager = getPluginManager(); const isPluginStatic = pluginManager.isPluginStaticPath(path); - if (path.startsWith('/static/') || path === '/' || path === '/favicon.ico' || path === '/index.html' || path.startsWith('/app/') || path === '/login.html' || isPluginStatic) { + if (path.startsWith('/static/') || path === '/' || path === '/favicon.ico' || path === '/index.html' || path.startsWith('/app/') || path.startsWith('/components/') || path === '/login.html' || isPluginStatic) { const served = await serveStaticFiles(path, res); if (served) return; } diff --git a/src/ui-manager.js b/src/ui-manager.js index 54a131f..030cd07 100644 --- a/src/ui-manager.js +++ b/src/ui-manager.js @@ -1,437 +1,20 @@ -import { existsSync, readFileSync, writeFileSync } from 'fs'; -import { promises as fs } from 'fs'; +import { existsSync, readFileSync } from 'fs'; import path from 'path'; -import os from 'os'; -import multer from 'multer'; -import crypto from 'crypto'; -import AdmZip from 'adm-zip'; -import { exec } from 'child_process'; -import { promisify } from 'util'; -import { getRequestBody } from './common.js'; -const execAsync = promisify(exec); +// Import UI modules +import * as auth from './ui-modules/auth.js'; +import * as configApi from './ui-modules/config-api.js'; +import * as providerApi from './ui-modules/provider-api.js'; +import * as usageApi from './ui-modules/usage-api.js'; +import * as pluginApi from './ui-modules/plugin-api.js'; +import * as uploadConfigApi from './ui-modules/upload-config-api.js'; +import * as systemApi from './ui-modules/system-api.js'; +import * as updateApi from './ui-modules/update-api.js'; +import * as oauthApi from './ui-modules/oauth-api.js'; +import * as eventBroadcast from './ui-modules/event-broadcast.js'; -// CPU 使用率计算相关变量 -let previousCpuInfo = null; - -/** - * 获取 CPU 使用率百分比 - * @returns {string} CPU 使用率字符串,如 "25.5%" - */ -function getCpuUsagePercent() { - const cpus = os.cpus(); - - let totalIdle = 0; - let totalTick = 0; - - for (const cpu of cpus) { - for (const type in cpu.times) { - totalTick += cpu.times[type]; - } - totalIdle += cpu.times.idle; - } - - const currentCpuInfo = { - idle: totalIdle, - total: totalTick - }; - - let cpuPercent = 0; - - if (previousCpuInfo) { - const idleDiff = currentCpuInfo.idle - previousCpuInfo.idle; - const totalDiff = currentCpuInfo.total - previousCpuInfo.total; - - if (totalDiff > 0) { - cpuPercent = 100 - (100 * idleDiff / totalDiff); - } - } - - previousCpuInfo = currentCpuInfo; - - return `${cpuPercent.toFixed(1)}%`; -} - -import { getAllProviderModels, getProviderModels } from './provider-models.js'; -import { CONFIG } from './config-manager.js'; -import { serviceInstances, getServiceAdapter } from './adapter.js'; -import { initApiService } from './service-manager.js'; -import { handleGeminiCliOAuth, handleGeminiAntigravityOAuth, handleQwenOAuth, handleKiroOAuth, handleIFlowOAuth, batchImportKiroRefreshTokens, batchImportKiroRefreshTokensStream, importAwsCredentials } from './oauth-handlers.js'; -import { getPluginManager } from './plugin-manager.js'; -import { - generateUUID, - normalizePath, - getFileName, - pathsEqual, - isPathUsed, - detectProviderFromPath, - isValidOAuthCredentials, - createProviderConfig, - addToUsedPaths, - formatSystemPath -} from './provider-utils.js'; -import { formatKiroUsage, formatGeminiUsage, formatAntigravityUsage } from './usage-service.js'; - -// Token存储到本地文件中 -const TOKEN_STORE_FILE = path.join(process.cwd(), 'configs', 'token-store.json'); - -// 用量缓存文件路径 -const USAGE_CACHE_FILE = path.join(process.cwd(), 'configs', 'usage-cache.json'); - -/** - * 读取用量缓存文件 - * @returns {Promise} 缓存的用量数据,如果不存在或读取失败则返回 null - */ -async function readUsageCache() { - try { - if (existsSync(USAGE_CACHE_FILE)) { - const content = await fs.readFile(USAGE_CACHE_FILE, 'utf8'); - return JSON.parse(content); - } - return null; - } catch (error) { - console.warn('[Usage Cache] Failed to read usage cache:', error.message); - return null; - } -} - -/** - * 写入用量缓存文件 - * @param {Object} usageData - 用量数据 - */ -async function writeUsageCache(usageData) { - try { - await fs.writeFile(USAGE_CACHE_FILE, JSON.stringify(usageData, null, 2), 'utf8'); - console.log('[Usage Cache] Usage data cached to', USAGE_CACHE_FILE); - } catch (error) { - console.error('[Usage Cache] Failed to write usage cache:', error.message); - } -} - -/** - * 读取特定提供商类型的用量缓存 - * @param {string} providerType - 提供商类型 - * @returns {Promise} 缓存的用量数据 - */ -async function readProviderUsageCache(providerType) { - const cache = await readUsageCache(); - if (cache && cache.providers && cache.providers[providerType]) { - return { - ...cache.providers[providerType], - cachedAt: cache.timestamp, - fromCache: true - }; - } - return null; -} - -/** - * 更新特定提供商类型的用量缓存 - * @param {string} providerType - 提供商类型 - * @param {Object} usageData - 用量数据 - */ -async function updateProviderUsageCache(providerType, usageData) { - let cache = await readUsageCache(); - if (!cache) { - cache = { - timestamp: new Date().toISOString(), - providers: {} - }; - } - cache.providers[providerType] = usageData; - cache.timestamp = new Date().toISOString(); - await writeUsageCache(cache); -} - -/** - * 读取token存储文件 - */ -async function readTokenStore() { - try { - if (existsSync(TOKEN_STORE_FILE)) { - const content = await fs.readFile(TOKEN_STORE_FILE, 'utf8'); - return JSON.parse(content); - } else { - // 如果文件不存在,创建一个默认的token store - await writeTokenStore({ tokens: {} }); - return { tokens: {} }; - } - } catch (error) { - console.error('[Token Store] Failed to read token store file:', error); - return { tokens: {} }; - } -} - -/** - * 写入token存储文件 - */ -async function writeTokenStore(tokenStore) { - try { - await fs.writeFile(TOKEN_STORE_FILE, JSON.stringify(tokenStore, null, 2), 'utf8'); - } catch (error) { - console.error('[Token Store] Failed to write token store file:', error); - } -} - -/** - * 生成简单的token - */ -function generateToken() { - return crypto.randomBytes(32).toString('hex'); -} - -/** - * 生成token过期时间 - */ -function getExpiryTime() { - const now = Date.now(); - const expiry = 60 * 60 * 1000; // 1小时 - return now + expiry; -} - -/** - * 验证简单token - */ -async function verifyToken(token) { - const tokenStore = await readTokenStore(); - const tokenInfo = tokenStore.tokens[token]; - if (!tokenInfo) { - return null; - } - - // 检查是否过期 - if (Date.now() > tokenInfo.expiryTime) { - await deleteToken(token); - return null; - } - - return tokenInfo; -} - -/** - * 保存token到本地文件 - */ -async function saveToken(token, tokenInfo) { - const tokenStore = await readTokenStore(); - tokenStore.tokens[token] = tokenInfo; - await writeTokenStore(tokenStore); -} - -/** - * 删除token - */ -async function deleteToken(token) { - const tokenStore = await readTokenStore(); - if (tokenStore.tokens[token]) { - delete tokenStore.tokens[token]; - await writeTokenStore(tokenStore); - } -} - -/** - * 清理过期的token - */ -async function cleanupExpiredTokens() { - const tokenStore = await readTokenStore(); - const now = Date.now(); - let hasChanges = false; - - for (const token in tokenStore.tokens) { - if (now > tokenStore.tokens[token].expiryTime) { - delete tokenStore.tokens[token]; - hasChanges = true; - } - } - - if (hasChanges) { - await writeTokenStore(tokenStore); - } -} - -/** - * 默认密码(当pwd文件不存在时使用) - */ -const DEFAULT_PASSWORD = 'admin123'; - -/** - * 读取密码文件内容 - * 如果文件不存在或读取失败,返回默认密码 - */ -async function readPasswordFile() { - const pwdFilePath = path.join(process.cwd(), 'configs', 'pwd'); - try { - // 使用异步方式检查文件是否存在并读取,避免竞态条件 - const password = await fs.readFile(pwdFilePath, 'utf8'); - const trimmedPassword = password.trim(); - // 如果密码文件为空,使用默认密码 - if (!trimmedPassword) { - console.log('[Auth] Password file is empty, using default password: ' + DEFAULT_PASSWORD); - return DEFAULT_PASSWORD; - } - console.log('[Auth] Successfully read password file'); - return trimmedPassword; - } catch (error) { - // ENOENT means file does not exist, which is normal - if (error.code === 'ENOENT') { - console.log('[Auth] Password file does not exist, using default password: ' + DEFAULT_PASSWORD); - } else { - console.error('[Auth] Failed to read password file:', error.code || error.message); - console.log('[Auth] Using default password: ' + DEFAULT_PASSWORD); - } - return DEFAULT_PASSWORD; - } -} - -/** - * 验证登录凭据 - */ -async function validateCredentials(password) { - const storedPassword = await readPasswordFile(); - console.log('[Auth] Validating password, stored password length:', storedPassword ? storedPassword.length : 0, ', input password length:', password ? password.length : 0); - const isValid = storedPassword && password === storedPassword; - console.log('[Auth] Password validation result:', isValid); - return isValid; -} - -/** - * 解析请求体JSON - */ -function parseRequestBody(req) { - return new Promise((resolve, reject) => { - let body = ''; - req.on('data', chunk => { - body += chunk.toString(); - }); - req.on('end', () => { - try { - if (!body.trim()) { - resolve({}); - } else { - resolve(JSON.parse(body)); - } - } catch (error) { - reject(new Error('Invalid JSON format')); - } - }); - req.on('error', reject); - }); -} - -/** - * 检查token验证 - */ -async function checkAuth(req) { - const authHeader = req.headers.authorization; - - if (!authHeader || !authHeader.startsWith('Bearer ')) { - return false; - } - - const token = authHeader.substring(7); - const tokenInfo = await verifyToken(token); - - return tokenInfo !== null; -} - -/** - * 处理登录请求 - */ -async function handleLoginRequest(req, res) { - if (req.method !== 'POST') { - res.writeHead(405, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ success: false, message: 'Only POST requests are supported' })); - return true; - } - - try { - const requestData = await parseRequestBody(req); - const { password } = requestData; - - if (!password) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ success: false, message: 'Password cannot be empty' })); - return true; - } - - const isValid = await validateCredentials(password); - - if (isValid) { - // Generate simple token - const token = generateToken(); - const expiryTime = getExpiryTime(); - - // Store token info to local file - await saveToken(token, { - username: 'admin', - loginTime: Date.now(), - expiryTime - }); - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: true, - message: 'Login successful', - token, - expiresIn: '1 hour' - })); - } else { - res.writeHead(401, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: false, - message: 'Incorrect password, please try again' - })); - } - } catch (error) { - console.error('[Auth] Login processing error:', error); - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: false, - message: error.message || 'Server error' - })); - } - return true; -} - -// 定时清理过期token -setInterval(cleanupExpiredTokens, 5 * 60 * 1000); // 每5分钟清理一次 - -// 配置multer中间件 -const storage = multer.diskStorage({ - destination: async (req, file, cb) => { - try { - // multer在destination回调时req.body还未解析,先使用默认路径 - // 实际的provider会在文件上传完成后从req.body中获取 - const uploadPath = path.join(process.cwd(), 'configs', 'temp'); - await fs.mkdir(uploadPath, { recursive: true }); - cb(null, uploadPath); - } catch (error) { - cb(error); - } - }, - filename: (req, file, cb) => { - const timestamp = Date.now(); - const sanitizedName = file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_'); - cb(null, `${timestamp}_${sanitizedName}`); - } -}); - -const fileFilter = (req, file, cb) => { - const allowedTypes = ['.json', '.txt', '.key', '.pem', '.p12', '.pfx']; - const ext = path.extname(file.originalname).toLowerCase(); - if (allowedTypes.includes(ext)) { - cb(null, true); - } else { - cb(new Error('Unsupported file type'), false); - } -}; - -const upload = multer({ - storage, - fileFilter, - limits: { - fileSize: 5 * 1024 * 1024 // 5MB限制 - } -}); +// Re-export from event-broadcast module +export { broadcastEvent, initializeUIManagement, handleUploadOAuthCredentials, upload } from './ui-modules/event-broadcast.js'; /** * Serve static files for the UI @@ -469,59 +52,21 @@ export async function serveStaticFiles(pathParam, res) { * @param {Object} providerPoolManager - The provider pool manager instance * @returns {Promise} - True if the request was handled by UI API */ -/** - * 重载配置文件 - * 动态导入config-manager并重新初始化配置 - * @returns {Promise} 返回重载后的配置对象 - */ -async function reloadConfig(providerPoolManager) { - try { - // Import config manager dynamically - const { initializeConfig } = await import('./config-manager.js'); - - // Reload main config - const newConfig = await initializeConfig(process.argv.slice(2), 'configs/config.json'); - // Update provider pool manager if available - if (providerPoolManager) { - providerPoolManager.providerPools = newConfig.providerPools; - providerPoolManager.initializeProviderStatus(); - } - - // Update global CONFIG - Object.assign(CONFIG, newConfig); - console.log('[UI API] Configuration reloaded:'); - - // Update initApiService - 清空并重新初始化服务实例 - Object.keys(serviceInstances).forEach(key => delete serviceInstances[key]); - initApiService(CONFIG); - - console.log('[UI API] Configuration reloaded successfully'); - - return newConfig; - } catch (error) { - console.error('[UI API] Failed to reload configuration:', error); - throw error; - } -} - export async function handleUIApiRequests(method, pathParam, req, res, currentConfig, providerPoolManager) { // 处理登录接口 if (method === 'POST' && pathParam === '/api/login') { - const handled = await handleLoginRequest(req, res); - if (handled) return true; + return await auth.handleLoginRequest(req, res); } // 健康检查接口(用于前端token验证) if (method === 'GET' && pathParam === '/api/health') { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ status: 'ok', timestamp: Date.now() })); - return true; + return await systemApi.handleHealthCheck(req, res); } // Handle UI management API requests (需要token验证,除了登录接口、健康检查和Events接口) if (pathParam.startsWith('/api/') && pathParam !== '/api/login' && pathParam !== '/api/health' && pathParam !== '/api/events' ) { // 检查token验证 - const isAuth = await checkAuth(req); + const isAuth = await auth.checkAuth(req); if (!isAuth) { res.writeHead(401, { 'Content-Type': 'application/json', @@ -540,365 +85,56 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo // 文件上传API if (method === 'POST' && pathParam === '/api/upload-oauth-credentials') { - return handleUploadOAuthCredentials(req, res); + return await eventBroadcast.handleUploadOAuthCredentials(req, res); } // Update admin password if (method === 'POST' && pathParam === '/api/admin-password') { - try { - const body = await getRequestBody(req); - const { password } = body; - - if (!password || password.trim() === '') { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: 'Password cannot be empty' - } - })); - return true; - } - - // 写入密码到 pwd 文件 - const pwdFilePath = path.join(process.cwd(), 'configs', 'pwd'); - await fs.writeFile(pwdFilePath, password.trim(), 'utf8'); - - console.log('[UI API] Admin password updated successfully'); - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: true, - message: 'Admin password updated successfully' - })); - return true; - } catch (error) { - console.error('[UI API] Failed to update admin password:', error); - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: 'Failed to update password: ' + error.message - } - })); - return true; - } + return await configApi.handleUpdateAdminPassword(req, res); } // Get configuration if (method === 'GET' && pathParam === '/api/config') { - let systemPrompt = ''; - - if (currentConfig.SYSTEM_PROMPT_FILE_PATH && existsSync(currentConfig.SYSTEM_PROMPT_FILE_PATH)) { - try { - systemPrompt = readFileSync(currentConfig.SYSTEM_PROMPT_FILE_PATH, 'utf-8'); - } catch (e) { - console.warn('[UI API] Failed to read system prompt file:', e.message); - } - } - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - ...currentConfig, - systemPrompt - })); - return true; + return await configApi.handleGetConfig(req, res, currentConfig); } // Update configuration if (method === 'POST' && pathParam === '/api/config') { - try { - const body = await getRequestBody(req); - const newConfig = body; - - // Update config values in memory - if (newConfig.REQUIRED_API_KEY !== undefined) currentConfig.REQUIRED_API_KEY = newConfig.REQUIRED_API_KEY; - 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.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; - if (newConfig.PROMPT_LOG_MODE !== undefined) currentConfig.PROMPT_LOG_MODE = newConfig.PROMPT_LOG_MODE; - if (newConfig.REQUEST_MAX_RETRIES !== undefined) currentConfig.REQUEST_MAX_RETRIES = newConfig.REQUEST_MAX_RETRIES; - if (newConfig.REQUEST_BASE_DELAY !== undefined) currentConfig.REQUEST_BASE_DELAY = newConfig.REQUEST_BASE_DELAY; - if (newConfig.CRON_NEAR_MINUTES !== undefined) currentConfig.CRON_NEAR_MINUTES = newConfig.CRON_NEAR_MINUTES; - if (newConfig.CRON_REFRESH_TOKEN !== undefined) currentConfig.CRON_REFRESH_TOKEN = newConfig.CRON_REFRESH_TOKEN; - if (newConfig.PROVIDER_POOLS_FILE_PATH !== undefined) currentConfig.PROVIDER_POOLS_FILE_PATH = newConfig.PROVIDER_POOLS_FILE_PATH; - if (newConfig.MAX_ERROR_COUNT !== undefined) currentConfig.MAX_ERROR_COUNT = newConfig.MAX_ERROR_COUNT; - if (newConfig.providerFallbackChain !== undefined) currentConfig.providerFallbackChain = newConfig.providerFallbackChain; - if (newConfig.modelFallbackMapping !== undefined) currentConfig.modelFallbackMapping = newConfig.modelFallbackMapping; - - // Proxy settings - if (newConfig.PROXY_URL !== undefined) currentConfig.PROXY_URL = newConfig.PROXY_URL; - if (newConfig.PROXY_ENABLED_PROVIDERS !== undefined) currentConfig.PROXY_ENABLED_PROVIDERS = newConfig.PROXY_ENABLED_PROVIDERS; - - // Handle system prompt update - if (newConfig.systemPrompt !== undefined) { - const promptPath = currentConfig.SYSTEM_PROMPT_FILE_PATH || 'configs/input_system_prompt.txt'; - try { - const relativePath = path.relative(process.cwd(), promptPath); - writeFileSync(promptPath, newConfig.systemPrompt, 'utf-8'); - - // 广播更新事件 - broadcastEvent('config_update', { - action: 'update', - filePath: relativePath, - type: 'system_prompt', - timestamp: new Date().toISOString() - }); - - console.log('[UI API] System prompt updated'); - } catch (e) { - console.warn('[UI API] Failed to write system prompt:', e.message); - } - } - - // Update config.json file - try { - const configPath = 'configs/config.json'; - - // Create a clean config object for saving (exclude runtime-only properties) - const configToSave = { - REQUIRED_API_KEY: currentConfig.REQUIRED_API_KEY, - SERVER_PORT: currentConfig.SERVER_PORT, - HOST: currentConfig.HOST, - MODEL_PROVIDER: currentConfig.MODEL_PROVIDER, - 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, - PROMPT_LOG_MODE: currentConfig.PROMPT_LOG_MODE, - REQUEST_MAX_RETRIES: currentConfig.REQUEST_MAX_RETRIES, - REQUEST_BASE_DELAY: currentConfig.REQUEST_BASE_DELAY, - CRON_NEAR_MINUTES: currentConfig.CRON_NEAR_MINUTES, - CRON_REFRESH_TOKEN: currentConfig.CRON_REFRESH_TOKEN, - PROVIDER_POOLS_FILE_PATH: currentConfig.PROVIDER_POOLS_FILE_PATH, - MAX_ERROR_COUNT: currentConfig.MAX_ERROR_COUNT, - providerFallbackChain: currentConfig.providerFallbackChain, - modelFallbackMapping: currentConfig.modelFallbackMapping, - PROXY_URL: currentConfig.PROXY_URL, - PROXY_ENABLED_PROVIDERS: currentConfig.PROXY_ENABLED_PROVIDERS - }; - - writeFileSync(configPath, JSON.stringify(configToSave, null, 2), 'utf-8'); - console.log('[UI API] Configuration saved to configs/config.json'); - - // 广播更新事件 - broadcastEvent('config_update', { - action: 'update', - filePath: 'configs/config.json', - type: 'main_config', - timestamp: new Date().toISOString() - }); - } catch (error) { - console.error('[UI API] Failed to save configuration to file:', error.message); - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: 'Failed to save configuration to file: ' + error.message, - partial: true // Indicate that memory config was updated but not saved - } - })); - return true; - } - - // Update the global CONFIG object to reflect changes immediately - Object.assign(CONFIG, currentConfig); - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: true, - message: 'Configuration updated successfully', - details: 'Configuration has been updated in both memory and config.json file' - })); - return true; - } catch (error) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: error.message } })); - return true; - } + return await configApi.handleUpdateConfig(req, res, currentConfig); } // Get system information if (method === 'GET' && pathParam === '/api/system') { - const memUsage = process.memoryUsage(); - - // 读取版本号 - let appVersion = 'unknown'; - try { - const versionFilePath = path.join(process.cwd(), 'VERSION'); - if (existsSync(versionFilePath)) { - appVersion = readFileSync(versionFilePath, 'utf8').trim(); - } - } catch (error) { - console.warn('[UI API] Failed to read VERSION file:', error.message); - } - - // 计算 CPU 使用率 - const cpuUsage = getCpuUsagePercent(); - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - appVersion: appVersion, - nodeVersion: process.version, - serverTime: new Date().toLocaleString(), - memoryUsage: `${Math.round(memUsage.heapUsed / 1024 / 1024)} MB / ${Math.round(memUsage.heapTotal / 1024 / 1024)} MB`, - cpuUsage: cpuUsage, - uptime: process.uptime() - })); - return true; + return await systemApi.handleGetSystem(req, res); } // Get provider pools summary if (method === 'GET' && pathParam === '/api/providers') { - let providerPools = {}; - const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; - try { - if (providerPoolManager && providerPoolManager.providerPools) { - providerPools = providerPoolManager.providerPools; - } else if (filePath && existsSync(filePath)) { - const poolsData = JSON.parse(readFileSync(filePath, 'utf-8')); - providerPools = poolsData; - } - } catch (error) { - console.warn('[UI API] Failed to load provider pools:', error.message); - } - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(providerPools)); - return true; + return await providerApi.handleGetProviders(req, res, currentConfig, providerPoolManager); } // Get specific provider type details const providerTypeMatch = pathParam.match(/^\/api\/providers\/([^\/]+)$/); if (method === 'GET' && providerTypeMatch) { const providerType = decodeURIComponent(providerTypeMatch[1]); - let providerPools = {}; - const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; - try { - if (providerPoolManager && providerPoolManager.providerPools) { - providerPools = providerPoolManager.providerPools; - } else if (filePath && existsSync(filePath)) { - const poolsData = JSON.parse(readFileSync(filePath, 'utf-8')); - providerPools = poolsData; - } - } catch (error) { - console.warn('[UI API] Failed to load provider pools:', error.message); - } - - const providers = providerPools[providerType] || []; - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - providerType, - providers, - totalCount: providers.length, - healthyCount: providers.filter(p => p.isHealthy).length - })); - return true; + return await providerApi.handleGetProviderType(req, res, currentConfig, providerPoolManager, providerType); } // Get available models for all providers or specific provider type if (method === 'GET' && pathParam === '/api/provider-models') { - const allModels = getAllProviderModels(); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(allModels)); - return true; + return await providerApi.handleGetProviderModels(req, res); } // Get available models for a specific provider type const providerModelsMatch = pathParam.match(/^\/api\/provider-models\/([^\/]+)$/); if (method === 'GET' && providerModelsMatch) { const providerType = decodeURIComponent(providerModelsMatch[1]); - const models = getProviderModels(providerType); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - providerType, - models - })); - return true; + return await providerApi.handleGetProviderTypeModels(req, res, providerType); } // Add new provider configuration if (method === 'POST' && pathParam === '/api/providers') { - try { - const body = await getRequestBody(req); - const { providerType, providerConfig } = body; - - if (!providerType || !providerConfig) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: 'providerType and providerConfig are required' } })); - return true; - } - - // Generate UUID if not provided - if (!providerConfig.uuid) { - providerConfig.uuid = generateUUID(); - } - - // Set default values - providerConfig.isHealthy = providerConfig.isHealthy !== undefined ? providerConfig.isHealthy : true; - providerConfig.lastUsed = providerConfig.lastUsed || null; - providerConfig.usageCount = providerConfig.usageCount || 0; - providerConfig.errorCount = providerConfig.errorCount || 0; - providerConfig.lastErrorTime = providerConfig.lastErrorTime || null; - - const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'provider_pools.json'; - let providerPools = {}; - - // Load existing pools - if (existsSync(filePath)) { - try { - const fileContent = readFileSync(filePath, 'utf8'); - providerPools = JSON.parse(fileContent); - } catch (readError) { - console.warn('[UI API] Failed to read existing provider pools:', readError.message); - } - } - - // Add new provider to the appropriate type - if (!providerPools[providerType]) { - providerPools[providerType] = []; - } - providerPools[providerType].push(providerConfig); - - // Save to file - writeFileSync(filePath, JSON.stringify(providerPools, null, 2), 'utf8'); - console.log(`[UI API] Added new provider to ${providerType}: ${providerConfig.uuid}`); - - // Update provider pool manager if available - if (providerPoolManager) { - providerPoolManager.providerPools = providerPools; - providerPoolManager.initializeProviderStatus(); - } - - // 广播更新事件 - broadcastEvent('config_update', { - action: 'add', - filePath: filePath, - providerType, - providerConfig, - timestamp: new Date().toISOString() - }); - - // 广播提供商更新事件 - broadcastEvent('provider_update', { - action: 'add', - providerType, - providerConfig, - timestamp: new Date().toISOString() - }); - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: true, - message: 'Provider added successfully', - provider: providerConfig, - providerType - })); - return true; - } catch (error) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: error.message } })); - return true; - } + return await providerApi.handleAddProvider(req, res, currentConfig, providerPoolManager); } // Update specific provider configuration @@ -906,159 +142,14 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo if (method === 'PUT' && updateProviderMatch) { const providerType = decodeURIComponent(updateProviderMatch[1]); const providerUuid = updateProviderMatch[2]; - - try { - const body = await getRequestBody(req); - const { providerConfig } = body; - - if (!providerConfig) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: 'providerConfig is required' } })); - return true; - } - - const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; - let providerPools = {}; - - // Load existing pools - if (existsSync(filePath)) { - try { - const fileContent = readFileSync(filePath, 'utf8'); - providerPools = JSON.parse(fileContent); - } catch (readError) { - res.writeHead(404, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: 'Provider pools file not found' } })); - return true; - } - } - - // Find and update the provider - const providers = providerPools[providerType] || []; - const providerIndex = providers.findIndex(p => p.uuid === providerUuid); - - if (providerIndex === -1) { - res.writeHead(404, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: 'Provider not found' } })); - return true; - } - - // Update provider while preserving certain fields - const existingProvider = providers[providerIndex]; - const updatedProvider = { - ...existingProvider, - ...providerConfig, - uuid: providerUuid, // Ensure UUID doesn't change - lastUsed: existingProvider.lastUsed, // Preserve usage stats - usageCount: existingProvider.usageCount, - errorCount: existingProvider.errorCount, - lastErrorTime: existingProvider.lastErrorTime - }; - - providerPools[providerType][providerIndex] = updatedProvider; - - // Save to file - writeFileSync(filePath, JSON.stringify(providerPools, null, 2), 'utf8'); - console.log(`[UI API] Updated provider ${providerUuid} in ${providerType}`); - - // Update provider pool manager if available - if (providerPoolManager) { - providerPoolManager.providerPools = providerPools; - providerPoolManager.initializeProviderStatus(); - } - - // 广播更新事件 - broadcastEvent('config_update', { - action: 'update', - filePath: filePath, - providerType, - providerConfig: updatedProvider, - timestamp: new Date().toISOString() - }); - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: true, - message: 'Provider updated successfully', - provider: updatedProvider - })); - return true; - } catch (error) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: error.message } })); - return true; - } + return await providerApi.handleUpdateProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid); } // Delete specific provider configuration if (method === 'DELETE' && updateProviderMatch) { const providerType = decodeURIComponent(updateProviderMatch[1]); const providerUuid = updateProviderMatch[2]; - - try { - const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; - let providerPools = {}; - - // Load existing pools - if (existsSync(filePath)) { - try { - const fileContent = readFileSync(filePath, 'utf8'); - providerPools = JSON.parse(fileContent); - } catch (readError) { - res.writeHead(404, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: 'Provider pools file not found' } })); - return true; - } - } - - // Find and remove the provider - const providers = providerPools[providerType] || []; - const providerIndex = providers.findIndex(p => p.uuid === providerUuid); - - if (providerIndex === -1) { - res.writeHead(404, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: 'Provider not found' } })); - return true; - } - - const deletedProvider = providers[providerIndex]; - providers.splice(providerIndex, 1); - - // Remove the entire provider type if no providers left - if (providers.length === 0) { - delete providerPools[providerType]; - } - - // Save to file - writeFileSync(filePath, JSON.stringify(providerPools, null, 2), 'utf8'); - console.log(`[UI API] Deleted provider ${providerUuid} from ${providerType}`); - - // Update provider pool manager if available - if (providerPoolManager) { - providerPoolManager.providerPools = providerPools; - providerPoolManager.initializeProviderStatus(); - } - - // 广播更新事件 - broadcastEvent('config_update', { - action: 'delete', - filePath: filePath, - providerType, - providerConfig: deletedProvider, - timestamp: new Date().toISOString() - }); - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: true, - message: 'Provider deleted successfully', - deletedProvider - })); - return true; - } catch (error) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: error.message } })); - return true; - } + return await providerApi.handleDeleteProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid); } // Disable/Enable specific provider configuration @@ -1067,2380 +158,127 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo const providerType = decodeURIComponent(disableEnableProviderMatch[1]); const providerUuid = disableEnableProviderMatch[2]; const action = disableEnableProviderMatch[3]; - - try { - const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; - let providerPools = {}; - - // Load existing pools - if (existsSync(filePath)) { - try { - const fileContent = readFileSync(filePath, 'utf8'); - providerPools = JSON.parse(fileContent); - } catch (readError) { - res.writeHead(404, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: 'Provider pools file not found' } })); - return true; - } - } - - // Find and update the provider - const providers = providerPools[providerType] || []; - const providerIndex = providers.findIndex(p => p.uuid === providerUuid); - - if (providerIndex === -1) { - res.writeHead(404, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: 'Provider not found' } })); - return true; - } - - // Update isDisabled field - const provider = providers[providerIndex]; - provider.isDisabled = action === 'disable'; - - // Save to file - writeFileSync(filePath, JSON.stringify(providerPools, null, 2), 'utf8'); - console.log(`[UI API] ${action === 'disable' ? 'Disabled' : 'Enabled'} provider ${providerUuid} in ${providerType}`); - - // Update provider pool manager if available - if (providerPoolManager) { - providerPoolManager.providerPools = providerPools; - - // Call the appropriate method - if (action === 'disable') { - providerPoolManager.disableProvider(providerType, provider); - } else { - providerPoolManager.enableProvider(providerType, provider); - } - } - - // 广播更新事件 - broadcastEvent('config_update', { - action: action, - filePath: filePath, - providerType, - providerConfig: provider, - timestamp: new Date().toISOString() - }); - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: true, - message: `Provider ${action}d successfully`, - provider: provider - })); - return true; - } catch (error) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: error.message } })); - return true; - } + return await providerApi.handleDisableEnableProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid, action); } // Reset all providers health status for a specific provider type const resetHealthMatch = pathParam.match(/^\/api\/providers\/([^\/]+)\/reset-health$/); if (method === 'POST' && resetHealthMatch) { const providerType = decodeURIComponent(resetHealthMatch[1]); - - try { - const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; - let providerPools = {}; - - // Load existing pools - if (existsSync(filePath)) { - try { - const fileContent = readFileSync(filePath, 'utf8'); - providerPools = JSON.parse(fileContent); - } catch (readError) { - res.writeHead(404, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: 'Provider pools file not found' } })); - return true; - } - } - - // Reset health status for all providers of this type - const providers = providerPools[providerType] || []; - - if (providers.length === 0) { - res.writeHead(404, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: 'No providers found for this type' } })); - return true; - } - - let resetCount = 0; - providers.forEach(provider => { - if (!provider.isHealthy) { - provider.isHealthy = true; - provider.errorCount = 0; - provider.lastErrorTime = null; - resetCount++; - } - }); - - // Save to file - writeFileSync(filePath, JSON.stringify(providerPools, null, 2), 'utf8'); - console.log(`[UI API] Reset health status for ${resetCount} providers in ${providerType}`); - - // Update provider pool manager if available - if (providerPoolManager) { - providerPoolManager.providerPools = providerPools; - providerPoolManager.initializeProviderStatus(); - } - - // 广播更新事件 - broadcastEvent('config_update', { - action: 'reset_health', - filePath: filePath, - providerType, - resetCount, - timestamp: new Date().toISOString() - }); - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: true, - message: `Successfully reset health status for ${resetCount} providers`, - resetCount, - totalCount: providers.length - })); - return true; - } catch (error) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: error.message } })); - return true; - } + return await providerApi.handleResetProviderHealth(req, res, currentConfig, providerPoolManager, providerType); } // Perform health check for all providers of a specific type const healthCheckMatch = pathParam.match(/^\/api\/providers\/([^\/]+)\/health-check$/); if (method === 'POST' && healthCheckMatch) { const providerType = decodeURIComponent(healthCheckMatch[1]); - - try { - if (!providerPoolManager) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: 'Provider pool manager not initialized' } })); - return true; - } - - const providers = providerPoolManager.providerStatus[providerType] || []; - - if (providers.length === 0) { - res.writeHead(404, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: 'No providers found for this type' } })); - return true; - } - - console.log(`[UI API] Starting health check for ${providers.length} providers in ${providerType}`); - - // 执行健康检测(强制检查,忽略 checkHealth 配置) - const results = []; - for (const providerStatus of providers) { - const providerConfig = providerStatus.config; - - // 跳过已禁用的节点 - if (providerConfig.isDisabled) { - console.log(`[UI API] Skipping health check for disabled provider: ${providerConfig.uuid}`); - continue; - } - - try { - // 传递 forceCheck = true 强制执行健康检查,忽略 checkHealth 配置 - const healthResult = await providerPoolManager._checkProviderHealth(providerType, providerConfig, true); - - if (healthResult === null) { - results.push({ - uuid: providerConfig.uuid, - success: null, - message: 'Health check not supported for this provider type' - }); - continue; - } - - if (healthResult.success) { - providerPoolManager.markProviderHealthy(providerType, providerConfig, false, healthResult.modelName); - results.push({ - uuid: providerConfig.uuid, - success: true, - modelName: healthResult.modelName, - message: 'Healthy' - }); - } else { - providerPoolManager.markProviderUnhealthy(providerType, providerConfig, healthResult.errorMessage); - providerStatus.config.lastHealthCheckTime = new Date().toISOString(); - if (healthResult.modelName) { - providerStatus.config.lastHealthCheckModel = healthResult.modelName; - } - results.push({ - uuid: providerConfig.uuid, - success: false, - modelName: healthResult.modelName, - message: healthResult.errorMessage || 'Check failed' - }); - } - } catch (error) { - providerPoolManager.markProviderUnhealthy(providerType, providerConfig, error.message); - results.push({ - uuid: providerConfig.uuid, - success: false, - message: error.message - }); - } - } - - // 保存更新后的状态到文件 - const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; - - // 从 providerStatus 构建 providerPools 对象并保存 - const providerPools = {}; - for (const pType in providerPoolManager.providerStatus) { - providerPools[pType] = providerPoolManager.providerStatus[pType].map(ps => ps.config); - } - writeFileSync(filePath, JSON.stringify(providerPools, null, 2), 'utf8'); - - const successCount = results.filter(r => r.success === true).length; - const failCount = results.filter(r => r.success === false).length; - - console.log(`[UI API] Health check completed for ${providerType}: ${successCount} healthy, ${failCount} unhealthy`); - - // 广播更新事件 - broadcastEvent('config_update', { - action: 'health_check', - filePath: filePath, - providerType, - results, - timestamp: new Date().toISOString() - }); - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: true, - message: `Health check completed: ${successCount} healthy, ${failCount} unhealthy`, - successCount, - failCount, - totalCount: providers.length, - results - })); - return true; - } catch (error) { - console.error('[UI API] Health check error:', error); - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: error.message } })); - return true; - } + return await providerApi.handleHealthCheck(req, res, currentConfig, providerPoolManager, providerType); } // Generate OAuth authorization URL for providers const generateAuthUrlMatch = pathParam.match(/^\/api\/providers\/([^\/]+)\/generate-auth-url$/); if (method === 'POST' && generateAuthUrlMatch) { const providerType = decodeURIComponent(generateAuthUrlMatch[1]); - - try { - let authUrl = ''; - let authInfo = {}; - - // 解析 options - let options = {}; - try { - options = await getRequestBody(req); - } catch (e) { - // 如果没有请求体,使用默认空对象 - } - - // 根据提供商类型生成授权链接并启动回调服务器 - if (providerType === 'gemini-cli-oauth') { - const result = await handleGeminiCliOAuth(currentConfig, options); - authUrl = result.authUrl; - authInfo = result.authInfo; - } else if (providerType === 'gemini-antigravity') { - const result = await handleGeminiAntigravityOAuth(currentConfig, options); - authUrl = result.authUrl; - authInfo = result.authInfo; - } else if (providerType === 'openai-qwen-oauth') { - const result = await handleQwenOAuth(currentConfig, options); - authUrl = result.authUrl; - authInfo = result.authInfo; - } else if (providerType === 'claude-kiro-oauth') { - // Kiro OAuth 支持多种认证方式 - // options.method 可以是: 'google' | 'github' | 'builder-id' - const result = await handleKiroOAuth(currentConfig, options); - authUrl = result.authUrl; - authInfo = result.authInfo; - } else if (providerType === 'openai-iflow') { - // iFlow OAuth 授权 - const result = await handleIFlowOAuth(currentConfig, options); - authUrl = result.authUrl; - authInfo = result.authInfo; - } else { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: `Unsupported provider type: ${providerType}` - } - })); - return true; - } - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: true, - authUrl: authUrl, - authInfo: authInfo - })); - return true; - - } catch (error) { - console.error(`[UI API] Failed to generate auth URL for ${providerType}:`, error); - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: `Failed to generate auth URL: ${error.message}` - } - })); - return true; - } + return await oauthApi.handleGenerateAuthUrl(req, res, currentConfig, providerType); } // 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; - } + return await oauthApi.handleManualOAuthCallback(req, res); } // Server-Sent Events for real-time updates if (method === 'GET' && pathParam === '/api/events') { - res.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', - 'Access-Control-Allow-Origin': '*' - }); - - res.write('\n'); - - // Store the response object for broadcasting - if (!global.eventClients) { - global.eventClients = []; - } - global.eventClients.push(res); - - // Keep connection alive - const keepAlive = setInterval(() => { - res.write(':\n\n'); - }, 30000); - - req.on('close', () => { - clearInterval(keepAlive); - global.eventClients = global.eventClients.filter(r => r !== res); - }); - - return true; + return await eventBroadcast.handleEvents(req, res); } // Get upload configuration files list if (method === 'GET' && pathParam === '/api/upload-configs') { - try { - const configFiles = await scanConfigFiles(currentConfig, providerPoolManager); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(configFiles)); - return true; - } catch (error) { - console.error('[UI API] Failed to scan config files:', error); - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: 'Failed to scan config files: ' + error.message - } - })); - return true; - } + return await uploadConfigApi.handleGetUploadConfigs(req, res, currentConfig, providerPoolManager); } // View specific configuration file const viewConfigMatch = pathParam.match(/^\/api\/upload-configs\/view\/(.+)$/); if (method === 'GET' && viewConfigMatch) { - try { - const filePath = decodeURIComponent(viewConfigMatch[1]); - const fullPath = path.join(process.cwd(), filePath); - - // 安全检查:确保文件路径在允许的目录内 - const allowedDirs = ['configs']; - const relativePath = path.relative(process.cwd(), fullPath); - const isAllowed = allowedDirs.some(dir => relativePath.startsWith(dir + path.sep) || relativePath === dir); - - if (!isAllowed) { - res.writeHead(403, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: 'Access denied: can only view files in configs directory' - } - })); - return true; - } - - if (!existsSync(fullPath)) { - res.writeHead(404, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: 'File does not exist' - } - })); - return true; - } - - const content = await fs.readFile(fullPath, 'utf8'); - const stats = await fs.stat(fullPath); - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - path: relativePath, - content: content, - size: stats.size, - modified: stats.mtime.toISOString(), - name: path.basename(fullPath) - })); - return true; - } catch (error) { - console.error('[UI API] Failed to view config file:', error); - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: 'Failed to view config file: ' + error.message - } - })); - return true; - } + const filePath = decodeURIComponent(viewConfigMatch[1]); + return await uploadConfigApi.handleViewConfigFile(req, res, filePath); } // Delete specific configuration file const deleteConfigMatch = pathParam.match(/^\/api\/upload-configs\/delete\/(.+)$/); if (method === 'DELETE' && deleteConfigMatch) { - try { - const filePath = decodeURIComponent(deleteConfigMatch[1]); - const fullPath = path.join(process.cwd(), filePath); - - // 安全检查:确保文件路径在允许的目录内 - const allowedDirs = ['configs']; - const relativePath = path.relative(process.cwd(), fullPath); - const isAllowed = allowedDirs.some(dir => relativePath.startsWith(dir + path.sep) || relativePath === dir); - - if (!isAllowed) { - res.writeHead(403, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: 'Access denied: can only delete files in configs directory' - } - })); - return true; - } - - if (!existsSync(fullPath)) { - res.writeHead(404, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: 'File does not exist' - } - })); - return true; - } - - - await fs.unlink(fullPath); - - // 广播更新事件 - broadcastEvent('config_update', { - action: 'delete', - filePath: relativePath, - timestamp: new Date().toISOString() - }); - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: true, - message: 'File deleted successfully', - filePath: relativePath - })); - return true; - } catch (error) { - console.error('[UI API] Failed to delete config file:', error); - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: 'Failed to delete config file: ' + error.message - } - })); - return true; - } + const filePath = decodeURIComponent(deleteConfigMatch[1]); + return await uploadConfigApi.handleDeleteConfigFile(req, res, filePath); } // Download all configs as zip if (method === 'GET' && pathParam === '/api/upload-configs/download-all') { - try { - const configsPath = path.join(process.cwd(), 'configs'); - if (!existsSync(configsPath)) { - res.writeHead(404, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: 'configs directory does not exist' } })); - return true; - } - - const zip = new AdmZip(); - - // 递归添加目录函数 - const addDirectoryToZip = async (dirPath, zipPath = '') => { - const items = await fs.readdir(dirPath, { withFileTypes: true }); - for (const item of items) { - const fullPath = path.join(dirPath, item.name); - const itemZipPath = zipPath ? path.join(zipPath, item.name) : item.name; - - if (item.isFile()) { - const content = await fs.readFile(fullPath); - zip.addFile(itemZipPath.replace(/\\/g, '/'), content); - } else if (item.isDirectory()) { - await addDirectoryToZip(fullPath, itemZipPath); - } - } - }; - - await addDirectoryToZip(configsPath); - - const zipBuffer = zip.toBuffer(); - const filename = `configs_backup_${new Date().toISOString().replace(/[:.]/g, '-')}.zip`; - - res.writeHead(200, { - 'Content-Type': 'application/zip', - 'Content-Disposition': `attachment; filename="${filename}"`, - 'Content-Length': zipBuffer.length - }); - res.end(zipBuffer); - - console.log(`[UI API] All configs downloaded as zip: ${filename}`); - return true; - } catch (error) { - console.error('[UI API] Failed to download all configs:', error); - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: 'Failed to download zip: ' + error.message - } - })); - return true; - } + return await uploadConfigApi.handleDownloadAllConfigs(req, res); } // Quick link config to corresponding provider based on directory if (method === 'POST' && pathParam === '/api/quick-link-provider') { - try { - const body = await getRequestBody(req); - const { filePath } = body; - - if (!filePath) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: 'filePath is required' } })); - return true; - } - - const normalizedPath = filePath.replace(/\\/g, '/').toLowerCase(); - - // 根据文件路径自动识别提供商类型 - const providerMapping = detectProviderFromPath(normalizedPath); - - if (!providerMapping) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: 'Unable to identify provider type for config file, please ensure file is in configs/kiro/, configs/gemini/, configs/qwen/ or configs/antigravity/ directory' - } - })); - return true; - } - - const { providerType, credPathKey, defaultCheckModel, displayName } = providerMapping; - const poolsFilePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; - - // Load existing pools - let providerPools = {}; - if (existsSync(poolsFilePath)) { - try { - const fileContent = readFileSync(poolsFilePath, 'utf8'); - providerPools = JSON.parse(fileContent); - } catch (readError) { - console.warn('[UI API] Failed to read existing provider pools:', readError.message); - } - } - - // Ensure provider type array exists - if (!providerPools[providerType]) { - providerPools[providerType] = []; - } - - // Check if already linked - 使用标准化路径进行比较 - const normalizedForComparison = filePath.replace(/\\/g, '/'); - const isAlreadyLinked = providerPools[providerType].some(p => { - const existingPath = p[credPathKey]; - if (!existingPath) return false; - const normalizedExistingPath = existingPath.replace(/\\/g, '/'); - return normalizedExistingPath === normalizedForComparison || - normalizedExistingPath === './' + normalizedForComparison || - './' + normalizedExistingPath === normalizedForComparison; - }); - - if (isAlreadyLinked) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: 'This config file is already linked' } })); - return true; - } - - // Create new provider config based on provider type - const newProvider = createProviderConfig({ - credPathKey, - credPath: formatSystemPath(filePath), - defaultCheckModel, - needsProjectId: providerMapping.needsProjectId - }); - - providerPools[providerType].push(newProvider); - - // Save to file - writeFileSync(poolsFilePath, JSON.stringify(providerPools, null, 2), 'utf8'); - console.log(`[UI API] Quick linked config: ${filePath} -> ${providerType}`); - - // Update provider pool manager if available - if (providerPoolManager) { - providerPoolManager.providerPools = providerPools; - providerPoolManager.initializeProviderStatus(); - } - - // Broadcast update event - broadcastEvent('config_update', { - action: 'quick_link', - filePath: poolsFilePath, - providerType, - newProvider, - timestamp: new Date().toISOString() - }); - - broadcastEvent('provider_update', { - action: 'add', - providerType, - providerConfig: newProvider, - timestamp: new Date().toISOString() - }); - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: true, - message: `Config successfully linked to ${displayName}`, - provider: newProvider, - providerType: providerType - })); - return true; - } catch (error) { - console.error('[UI API] Quick link failed:', error); - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: 'Link failed: ' + error.message - } - })); - return true; - } + return await providerApi.handleQuickLinkProvider(req, res, currentConfig, providerPoolManager); } // Get usage limits for all providers if (method === 'GET' && pathParam === '/api/usage') { - try { - // 解析查询参数,检查是否需要强制刷新 - const url = new URL(req.url, `http://${req.headers.host}`); - const refresh = url.searchParams.get('refresh') === 'true'; - - let usageResults; - - if (!refresh) { - // 优先读取缓存 - const cachedData = await readUsageCache(); - if (cachedData) { - console.log('[Usage API] Returning cached usage data'); - usageResults = { ...cachedData, fromCache: true }; - } - } - - if (!usageResults) { - // 缓存不存在或需要刷新,重新查询 - console.log('[Usage API] Fetching fresh usage data'); - usageResults = await getAllProvidersUsage(currentConfig, providerPoolManager); - // 写入缓存 - await writeUsageCache(usageResults); - } - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(usageResults)); - return true; - } catch (error) { - console.error('[UI API] Failed to get usage:', error); - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: 'Failed to get usage info: ' + error.message - } - })); - return true; - } + return await usageApi.handleGetUsage(req, res, currentConfig, providerPoolManager); } // Get usage limits for a specific provider type const usageProviderMatch = pathParam.match(/^\/api\/usage\/([^\/]+)$/); if (method === 'GET' && usageProviderMatch) { const providerType = decodeURIComponent(usageProviderMatch[1]); - try { - // 解析查询参数,检查是否需要强制刷新 - const url = new URL(req.url, `http://${req.headers.host}`); - const refresh = url.searchParams.get('refresh') === 'true'; - - let usageResults; - - if (!refresh) { - // Prefer reading from cache - const cachedData = await readProviderUsageCache(providerType); - if (cachedData) { - console.log(`[Usage API] Returning cached usage data for ${providerType}`); - usageResults = cachedData; - } - } - - if (!usageResults) { - // Cache does not exist or refresh required, re-query - console.log(`[Usage API] Fetching fresh usage data for ${providerType}`); - usageResults = await getProviderTypeUsage(providerType, currentConfig, providerPoolManager); - // 更新缓存 - await updateProviderUsageCache(providerType, usageResults); - } - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(usageResults)); - return true; - } catch (error) { - console.error(`[UI API] Failed to get usage for ${providerType}:`, error); - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: `Failed to get usage info for ${providerType}: ` + error.message - } - })); - return true; - } + return await usageApi.handleGetProviderUsage(req, res, currentConfig, providerPoolManager, providerType); } // Check for updates - compare local VERSION with latest git tag if (method === 'GET' && pathParam === '/api/check-update') { - try { - const updateInfo = await checkForUpdates(); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(updateInfo)); - return true; - } catch (error) { - console.error('[UI API] Failed to check for updates:', error); - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: 'Failed to check for updates: ' + error.message - } - })); - return true; - } + return await updateApi.handleCheckUpdate(req, res); } // Perform update - git fetch and checkout to latest tag if (method === 'POST' && pathParam === '/api/update') { - try { - const updateResult = await performUpdate(); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(updateResult)); - return true; - } catch (error) { - console.error('[UI API] Failed to perform update:', error); - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: 'Update failed: ' + error.message - } - })); - return true; - } + return await updateApi.handlePerformUpdate(req, res); } // Reload configuration files if (method === 'POST' && pathParam === '/api/reload-config') { - try { - // 调用重载配置函数 - const newConfig = await reloadConfig(providerPoolManager); - - // 广播更新事件 - broadcastEvent('config_update', { - action: 'reload', - filePath: 'configs/config.json', - providerPoolsPath: newConfig.PROVIDER_POOLS_FILE_PATH || null, - timestamp: new Date().toISOString() - }); - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: true, - message: 'Configuration files reloaded successfully', - details: { - configReloaded: true, - configPath: 'configs/config.json', - providerPoolsPath: newConfig.PROVIDER_POOLS_FILE_PATH || null - } - })); - return true; - } catch (error) { - console.error('[UI API] Failed to reload config files:', error); - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: 'Failed to reload configuration files: ' + error.message - } - })); - return true; - } + return await configApi.handleReloadConfig(req, res, providerPoolManager); } // Restart service (worker process) - // 重启服务端点 - 支持主进程-子进程架构 if (method === 'POST' && pathParam === '/api/restart-service') { - try { - const IS_WORKER_PROCESS = process.env.IS_WORKER_PROCESS === 'true'; - - if (IS_WORKER_PROCESS && process.send) { - // 作为子进程运行,通知主进程重启 - console.log('[UI API] Requesting restart from master process...'); - process.send({ type: 'restart_request' }); - - // 广播重启事件 - broadcastEvent('service_restart', { - action: 'restart_requested', - timestamp: new Date().toISOString(), - message: 'Service restart requested, worker will be restarted by master process' - }); - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: true, - message: 'Restart request sent to master process', - mode: 'worker', - details: { - workerPid: process.pid, - restartMethod: 'master_controlled' - } - })); - } else { - // 独立运行模式,无法自动重启 - console.log('[UI API] Service is running in standalone mode, cannot auto-restart'); - - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: false, - message: 'Service is running in standalone mode. Please use master.js to enable auto-restart feature.', - mode: 'standalone', - hint: 'Start the service with: node src/master.js [args]' - })); - } - return true; - } catch (error) { - console.error('[UI API] Failed to restart service:', error); - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: 'Failed to restart service: ' + error.message - } - })); - return true; - } + return await systemApi.handleRestartService(req, res); } // Get service mode information - // 获取服务运行模式信息 if (method === 'GET' && pathParam === '/api/service-mode') { - const IS_WORKER_PROCESS = process.env.IS_WORKER_PROCESS === 'true'; - const masterPort = process.env.MASTER_PORT || 3100; - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - mode: IS_WORKER_PROCESS ? 'worker' : 'standalone', - pid: process.pid, - ppid: process.ppid, - uptime: process.uptime(), - canAutoRestart: IS_WORKER_PROCESS && !!process.send, - masterPort: IS_WORKER_PROCESS ? masterPort : null, - nodeVersion: process.version, - platform: process.platform - })); - return true; + return await systemApi.handleGetServiceMode(req, res); } // Batch import Kiro refresh tokens with SSE (real-time progress) - // 批量导入 Kiro refreshToken(带实时进度 SSE) if (method === 'POST' && pathParam === '/api/kiro/batch-import-tokens') { - try { - const body = await getRequestBody(req); - const { refreshTokens, region } = body; - - if (!refreshTokens || !Array.isArray(refreshTokens) || refreshTokens.length === 0) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: false, - error: 'refreshTokens array is required and must not be empty' - })); - return true; - } - - console.log(`[Kiro Batch Import] Starting batch import of ${refreshTokens.length} tokens with SSE...`); - - // 设置 SSE 响应头 - res.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', - 'X-Accel-Buffering': 'no' - }); - - // 发送 SSE 事件的辅助函数 - const sendSSE = (event, data) => { - res.write(`event: ${event}\n`); - res.write(`data: ${JSON.stringify(data)}\n\n`); - }; - - // 发送开始事件 - sendSSE('start', { total: refreshTokens.length }); - - // 执行流式批量导入 - const result = await batchImportKiroRefreshTokensStream( - refreshTokens, - region || 'us-east-1', - (progress) => { - // 每处理完一个 token 发送进度更新 - sendSSE('progress', progress); - } - ); - - console.log(`[Kiro Batch Import] Completed: ${result.success} success, ${result.failed} failed`); - - // 发送完成事件 - sendSSE('complete', { - success: true, - total: result.total, - successCount: result.success, - failedCount: result.failed, - details: result.details - }); - - res.end(); - return true; - - } catch (error) { - console.error('[Kiro Batch Import] Error:', error); - // 如果已经开始发送 SSE,则发送错误事件 - if (res.headersSent) { - res.write(`event: error\n`); - res.write(`data: ${JSON.stringify({ error: error.message })}\n\n`); - res.end(); - } else { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: false, - error: error.message - })); - } - return true; - } + return await oauthApi.handleBatchImportKiroTokens(req, res); } // Import AWS SSO credentials for Kiro - // 导入 AWS SSO 凭据用于 Kiro if (method === 'POST' && pathParam === '/api/kiro/import-aws-credentials') { - try { - const body = await getRequestBody(req); - const { credentials } = body; - - if (!credentials || typeof credentials !== 'object') { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: false, - error: 'credentials object is required' - })); - return true; - } - - // 验证必需字段 - 需要四个字段都存在 - const missingFields = []; - if (!credentials.clientId) missingFields.push('clientId'); - if (!credentials.clientSecret) missingFields.push('clientSecret'); - if (!credentials.accessToken) missingFields.push('accessToken'); - if (!credentials.refreshToken) missingFields.push('refreshToken'); - - if (missingFields.length > 0) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: false, - error: `Missing required fields: ${missingFields.join(', ')}` - })); - return true; - } - - console.log('[Kiro AWS Import] Starting AWS credentials import...'); - - const result = await importAwsCredentials(credentials); - - if (result.success) { - console.log(`[Kiro AWS Import] Successfully imported credentials to: ${result.path}`); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: true, - path: result.path, - message: 'AWS credentials imported successfully' - })); - } else { - // 重复凭据返回 409 Conflict,其他错误返回 500 - const statusCode = result.error === 'duplicate' ? 409 : 500; - res.writeHead(statusCode, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: false, - error: result.error, - existingPath: result.existingPath || null - })); - } - return true; - - } catch (error) { - console.error('[Kiro AWS Import] Error:', error); - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: false, - error: error.message - })); - return true; - } + return await oauthApi.handleImportAwsCredentials(req, res); } // Get plugins list if (method === 'GET' && pathParam === '/api/plugins') { - try { - const pluginManager = getPluginManager(); - const plugins = pluginManager.getPluginList(); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ plugins })); - return true; - } catch (error) { - console.error('[UI API] Failed to get plugins:', error); - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: 'Failed to get plugins list: ' + error.message - } - })); - return true; - } + return await pluginApi.handleGetPlugins(req, res); } // Toggle plugin status const togglePluginMatch = pathParam.match(/^\/api\/plugins\/(.+)\/toggle$/); if (method === 'POST' && togglePluginMatch) { - try { - const pluginName = decodeURIComponent(togglePluginMatch[1]); - const body = await getRequestBody(req); - const { enabled } = body; - - if (typeof enabled !== 'boolean') { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: 'Enabled status must be a boolean' - } - })); - return true; - } - - const pluginManager = getPluginManager(); - await pluginManager.setPluginEnabled(pluginName, enabled); - - // 广播更新事件 - broadcastEvent('plugin_update', { - action: 'toggle', - pluginName, - enabled, - timestamp: new Date().toISOString() - }); - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: true, - message: `Plugin ${pluginName} ${enabled ? 'enabled' : 'disabled'} successfully`, - plugin: { - name: pluginName, - enabled - } - })); - return true; - } catch (error) { - console.error('[UI API] Failed to toggle plugin:', error); - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: 'Failed to toggle plugin: ' + error.message - } - })); - return true; - } + const pluginName = decodeURIComponent(togglePluginMatch[1]); + return await pluginApi.handleTogglePlugin(req, res, pluginName); } return false; -} - -/** - * Initialize UI management features - */ -export function initializeUIManagement() { - // Initialize log broadcasting for UI - if (!global.eventClients) { - global.eventClients = []; - } - if (!global.logBuffer) { - global.logBuffer = []; - } - - // Override console.log to broadcast logs - const originalLog = console.log; - console.log = function(...args) { - originalLog.apply(console, args); - const message = args.map(arg => { - if (typeof arg === 'string') return arg; - try { - return JSON.stringify(arg); - } catch (e) { - if (arg instanceof Error) { - return `[Error: ${arg.message}] ${arg.stack || ''}`; - } - return `[Object: ${Object.prototype.toString.call(arg)}] (Circular or too complex to stringify)`; - } - }).join(' '); - const logEntry = { - timestamp: new Date().toISOString(), - level: 'info', - message: message - }; - global.logBuffer.push(logEntry); - if (global.logBuffer.length > 100) { - global.logBuffer.shift(); - } - broadcastEvent('log', logEntry); - }; - - // Override console.error to broadcast errors - const originalError = console.error; - console.error = function(...args) { - originalError.apply(console, args); - const message = args.map(arg => { - if (typeof arg === 'string') return arg; - try { - return JSON.stringify(arg); - } catch (e) { - if (arg instanceof Error) { - return `[Error: ${arg.message}] ${arg.stack || ''}`; - } - return `[Object: ${Object.prototype.toString.call(arg)}] (Circular or too complex to stringify)`; - } - }).join(' '); - const logEntry = { - timestamp: new Date().toISOString(), - level: 'error', - message: message - }; - global.logBuffer.push(logEntry); - if (global.logBuffer.length > 100) { - global.logBuffer.shift(); - } - broadcastEvent('log', logEntry); - }; -} - -/** - * Helper function to broadcast events to UI clients - * @param {string} eventType - The type of event - * @param {any} data - The data to broadcast - */ -export function broadcastEvent(eventType, data) { - if (global.eventClients && global.eventClients.length > 0) { - const payload = typeof data === 'string' ? data : JSON.stringify(data); - global.eventClients.forEach(client => { - client.write(`event: ${eventType}\n`); - client.write(`data: ${payload}\n\n`); - }); - } -} - -/** - * Scan and analyze configuration files - * @param {Object} currentConfig - The current configuration object - * @param {Object} providerPoolManager - Provider pool manager instance - * @returns {Promise} Array of configuration file objects - */ -async function scanConfigFiles(currentConfig, providerPoolManager) { - const configFiles = []; - - // 只扫描configs目录 - const configsPath = path.join(process.cwd(), 'configs'); - - if (!existsSync(configsPath)) { - // console.log('[Config Scanner] configs directory not found, creating empty result'); - return configFiles; - } - - const usedPaths = new Set(); // 存储已使用的路径,用于判断关联状态 - - // 从配置中提取所有OAuth凭据文件路径 - 标准化路径格式 - addToUsedPaths(usedPaths, currentConfig.GEMINI_OAUTH_CREDS_FILE_PATH); - addToUsedPaths(usedPaths, currentConfig.KIRO_OAUTH_CREDS_FILE_PATH); - addToUsedPaths(usedPaths, currentConfig.QWEN_OAUTH_CREDS_FILE_PATH); - addToUsedPaths(usedPaths, currentConfig.ANTIGRAVITY_OAUTH_CREDS_FILE_PATH); - addToUsedPaths(usedPaths, currentConfig.IFLOW_TOKEN_FILE_PATH); - - // 使用最新的提供商池数据 - let providerPools = currentConfig.providerPools; - if (providerPoolManager && providerPoolManager.providerPools) { - providerPools = providerPoolManager.providerPools; - } - - // 检查提供商池文件中的所有OAuth凭据路径 - 标准化路径格式 - if (providerPools) { - for (const [providerType, providers] of Object.entries(providerPools)) { - for (const provider of providers) { - addToUsedPaths(usedPaths, provider.GEMINI_OAUTH_CREDS_FILE_PATH); - addToUsedPaths(usedPaths, provider.KIRO_OAUTH_CREDS_FILE_PATH); - addToUsedPaths(usedPaths, provider.QWEN_OAUTH_CREDS_FILE_PATH); - addToUsedPaths(usedPaths, provider.ANTIGRAVITY_OAUTH_CREDS_FILE_PATH); - addToUsedPaths(usedPaths, provider.IFLOW_TOKEN_FILE_PATH); - } - } - } - - try { - // 扫描configs目录下的所有子目录和文件 - const configsFiles = await scanOAuthDirectory(configsPath, usedPaths, currentConfig); - configFiles.push(...configsFiles); - } catch (error) { - console.warn(`[Config Scanner] Failed to scan configs directory:`, error.message); - } - - return configFiles; -} - -/** - * Analyze OAuth configuration file and return metadata - * @param {string} filePath - Full path to the file - * @param {Set} usedPaths - Set of paths currently in use - * @returns {Promise} OAuth file information object - */ -async function analyzeOAuthFile(filePath, usedPaths, currentConfig) { - try { - const stats = await fs.stat(filePath); - const ext = path.extname(filePath).toLowerCase(); - const filename = path.basename(filePath); - const relativePath = path.relative(process.cwd(), filePath); - - // 读取文件内容进行分析 - let content = ''; - let type = 'oauth_credentials'; - let isValid = true; - let errorMessage = ''; - let oauthProvider = 'unknown'; - let usageInfo = getFileUsageInfo(relativePath, filename, usedPaths, currentConfig); - - try { - if (ext === '.json') { - const rawContent = await fs.readFile(filePath, 'utf8'); - const jsonData = JSON.parse(rawContent); - content = rawContent; - - // 识别OAuth提供商 - if (jsonData.apiKey || jsonData.api_key) { - type = 'api_key'; - } else if (jsonData.client_id || jsonData.client_secret) { - oauthProvider = 'oauth2'; - } else if (jsonData.access_token || jsonData.refresh_token) { - oauthProvider = 'token_based'; - } else if (jsonData.credentials) { - oauthProvider = 'service_account'; - } - - if (jsonData.base_url || jsonData.endpoint) { - if (jsonData.base_url.includes('openai.com')) { - oauthProvider = 'openai'; - } else if (jsonData.base_url.includes('anthropic.com')) { - oauthProvider = 'claude'; - } else if (jsonData.base_url.includes('googleapis.com')) { - oauthProvider = 'gemini'; - } - } - } else { - content = await fs.readFile(filePath, 'utf8'); - - if (ext === '.key' || ext === '.pem') { - if (content.includes('-----BEGIN') && content.includes('PRIVATE KEY-----')) { - oauthProvider = 'private_key'; - } - } else if (ext === '.txt') { - if (content.includes('api_key') || content.includes('apikey')) { - oauthProvider = 'api_key'; - } - } else if (ext === '.oauth' || ext === '.creds') { - oauthProvider = 'oauth_credentials'; - } - } - } catch (readError) { - isValid = false; - errorMessage = `Unable to read file: ${readError.message}`; - } - - return { - name: filename, - path: relativePath, - size: stats.size, - type: type, - provider: oauthProvider, - extension: ext, - modified: stats.mtime.toISOString(), - isValid: isValid, - errorMessage: errorMessage, - isUsed: isPathUsed(relativePath, filename, usedPaths), - usageInfo: usageInfo, // 新增详细关联信息 - preview: content.substring(0, 100) + (content.length > 100 ? '...' : '') - }; - } catch (error) { - console.warn(`[OAuth Analyzer] Failed to analyze file ${filePath}:`, error.message); - return null; - } -} - -/** - * Get detailed usage information for a file - * @param {string} relativePath - Relative file path - * @param {string} fileName - File name - * @param {Set} usedPaths - Set of used paths - * @param {Object} currentConfig - Current configuration - * @returns {Object} Usage information object - */ -function getFileUsageInfo(relativePath, fileName, usedPaths, currentConfig) { - const usageInfo = { - isUsed: false, - usageType: null, - usageDetails: [] - }; - - // 检查是否被使用 - const isUsed = isPathUsed(relativePath, fileName, usedPaths); - if (!isUsed) { - return usageInfo; - } - - usageInfo.isUsed = true; - - // 检查主要配置中的使用情况 - if (currentConfig.GEMINI_OAUTH_CREDS_FILE_PATH && - (pathsEqual(relativePath, currentConfig.GEMINI_OAUTH_CREDS_FILE_PATH) || - pathsEqual(relativePath, currentConfig.GEMINI_OAUTH_CREDS_FILE_PATH.replace(/\\/g, '/')))) { - usageInfo.usageType = 'main_config'; - usageInfo.usageDetails.push({ - type: 'Main Config', - location: 'Gemini OAuth credentials file path', - configKey: 'GEMINI_OAUTH_CREDS_FILE_PATH' - }); - } - - if (currentConfig.KIRO_OAUTH_CREDS_FILE_PATH && - (pathsEqual(relativePath, currentConfig.KIRO_OAUTH_CREDS_FILE_PATH) || - pathsEqual(relativePath, currentConfig.KIRO_OAUTH_CREDS_FILE_PATH.replace(/\\/g, '/')))) { - usageInfo.usageType = 'main_config'; - usageInfo.usageDetails.push({ - type: 'Main Config', - location: 'Kiro OAuth credentials file path', - configKey: 'KIRO_OAUTH_CREDS_FILE_PATH' - }); - } - - if (currentConfig.QWEN_OAUTH_CREDS_FILE_PATH && - (pathsEqual(relativePath, currentConfig.QWEN_OAUTH_CREDS_FILE_PATH) || - pathsEqual(relativePath, currentConfig.QWEN_OAUTH_CREDS_FILE_PATH.replace(/\\/g, '/')))) { - usageInfo.usageType = 'main_config'; - usageInfo.usageDetails.push({ - type: 'Main Config', - location: 'Qwen OAuth credentials file path', - configKey: 'QWEN_OAUTH_CREDS_FILE_PATH' - }); - } - - if (currentConfig.IFLOW_TOKEN_FILE_PATH && - (pathsEqual(relativePath, currentConfig.IFLOW_TOKEN_FILE_PATH) || - pathsEqual(relativePath, currentConfig.IFLOW_TOKEN_FILE_PATH.replace(/\\/g, '/')))) { - usageInfo.usageType = 'main_config'; - usageInfo.usageDetails.push({ - type: 'Main Config', - location: 'iFlow Token file path', - configKey: 'IFLOW_TOKEN_FILE_PATH' - }); - } - - // 检查提供商池中的使用情况 - if (currentConfig.providerPools) { - // 使用 flatMap 将双重循环优化为单层循环 O(n) - const allProviders = Object.entries(currentConfig.providerPools).flatMap( - ([providerType, providers]) => - providers.map((provider, index) => ({ provider, providerType, index })) - ); - - for (const { provider, providerType, index } of allProviders) { - const providerUsages = []; - - if (provider.GEMINI_OAUTH_CREDS_FILE_PATH && - (pathsEqual(relativePath, provider.GEMINI_OAUTH_CREDS_FILE_PATH) || - pathsEqual(relativePath, provider.GEMINI_OAUTH_CREDS_FILE_PATH.replace(/\\/g, '/')))) { - providerUsages.push({ - type: 'Provider Pool', - location: `Gemini OAuth credentials (node ${index + 1})`, - providerType: providerType, - providerIndex: index, - configKey: 'GEMINI_OAUTH_CREDS_FILE_PATH' - }); - } - - if (provider.KIRO_OAUTH_CREDS_FILE_PATH && - (pathsEqual(relativePath, provider.KIRO_OAUTH_CREDS_FILE_PATH) || - pathsEqual(relativePath, provider.KIRO_OAUTH_CREDS_FILE_PATH.replace(/\\/g, '/')))) { - providerUsages.push({ - type: 'Provider Pool', - location: `Kiro OAuth credentials (node ${index + 1})`, - providerType: providerType, - providerIndex: index, - configKey: 'KIRO_OAUTH_CREDS_FILE_PATH' - }); - } - - if (provider.QWEN_OAUTH_CREDS_FILE_PATH && - (pathsEqual(relativePath, provider.QWEN_OAUTH_CREDS_FILE_PATH) || - pathsEqual(relativePath, provider.QWEN_OAUTH_CREDS_FILE_PATH.replace(/\\/g, '/')))) { - providerUsages.push({ - type: 'Provider Pool', - location: `Qwen OAuth credentials (node ${index + 1})`, - providerType: providerType, - providerIndex: index, - configKey: 'QWEN_OAUTH_CREDS_FILE_PATH' - }); - } - - if (provider.ANTIGRAVITY_OAUTH_CREDS_FILE_PATH && - (pathsEqual(relativePath, provider.ANTIGRAVITY_OAUTH_CREDS_FILE_PATH) || - pathsEqual(relativePath, provider.ANTIGRAVITY_OAUTH_CREDS_FILE_PATH.replace(/\\/g, '/')))) { - providerUsages.push({ - type: 'Provider Pool', - location: `Antigravity OAuth credentials (node ${index + 1})`, - providerType: providerType, - providerIndex: index, - configKey: 'ANTIGRAVITY_OAUTH_CREDS_FILE_PATH' - }); - } - - if (provider.IFLOW_TOKEN_FILE_PATH && - (pathsEqual(relativePath, provider.IFLOW_TOKEN_FILE_PATH) || - pathsEqual(relativePath, provider.IFLOW_TOKEN_FILE_PATH.replace(/\\/g, '/')))) { - providerUsages.push({ - type: 'Provider Pool', - location: `iFlow Token (node ${index + 1})`, - providerType: providerType, - providerIndex: index, - configKey: 'IFLOW_TOKEN_FILE_PATH' - }); - } - - if (providerUsages.length > 0) { - usageInfo.usageType = 'provider_pool'; - usageInfo.usageDetails.push(...providerUsages); - } - } - } - - // 如果有多个使用位置,标记为多种用途 - if (usageInfo.usageDetails.length > 1) { - usageInfo.usageType = 'multiple'; - } - - return usageInfo; -} - -/** - * Scan OAuth directory for credential files - * @param {string} dirPath - Directory path to scan - * @param {Set} usedPaths - Set of used paths - * @param {Object} currentConfig - Current configuration - * @returns {Promise} Array of OAuth configuration file objects - */ -async function scanOAuthDirectory(dirPath, usedPaths, currentConfig) { - const oauthFiles = []; - - try { - const files = await fs.readdir(dirPath, { withFileTypes: true }); - - for (const file of files) { - const fullPath = path.join(dirPath, file.name); - - if (file.isFile()) { - const ext = path.extname(file.name).toLowerCase(); - // 只关注OAuth相关的文件类型 - if (['.json', '.oauth', '.creds', '.key', '.pem', '.txt'].includes(ext)) { - const fileInfo = await analyzeOAuthFile(fullPath, usedPaths, currentConfig); - if (fileInfo) { - oauthFiles.push(fileInfo); - } - } - } else if (file.isDirectory()) { - // 递归扫描子目录(限制深度) - const relativePath = path.relative(process.cwd(), fullPath); - // 最大深度4层,以支持 configs/kiro/{subfolder}/file.json 这样的结构 - if (relativePath.split(path.sep).length < 4) { - const subFiles = await scanOAuthDirectory(fullPath, usedPaths, currentConfig); - oauthFiles.push(...subFiles); - } - } - } - } catch (error) { - console.warn(`[OAuth Scanner] Failed to scan directory ${dirPath}:`, error.message); - } - - return oauthFiles; -} - - -// 注意:normalizePath, getFileName, pathsEqual, isPathUsed, detectProviderFromPath -// 已移至 provider-utils.js 公共模块 - -/** - * 获取所有支持用量查询的提供商的用量信息 - * @param {Object} currentConfig - 当前配置 - * @param {Object} providerPoolManager - 提供商池管理器 - * @returns {Promise} 所有提供商的用量信息 - */ -async function getAllProvidersUsage(currentConfig, providerPoolManager) { - const results = { - timestamp: new Date().toISOString(), - providers: {} - }; - - // 支持用量查询的提供商列表 - const supportedProviders = ['claude-kiro-oauth', 'gemini-cli-oauth', 'gemini-antigravity']; - - // 并发获取所有提供商的用量数据 - const usagePromises = supportedProviders.map(async (providerType) => { - try { - const providerUsage = await getProviderTypeUsage(providerType, currentConfig, providerPoolManager); - return { providerType, data: providerUsage, success: true }; - } catch (error) { - return { - providerType, - data: { - error: error.message, - instances: [] - }, - success: false - }; - } - }); - - // 等待所有并发请求完成 - const usageResults = await Promise.all(usagePromises); - - // 将结果整合到 results.providers 中 - for (const result of usageResults) { - results.providers[result.providerType] = result.data; - } - - return results; -} - -/** - * 获取指定提供商类型的用量信息 - * @param {string} providerType - 提供商类型 - * @param {Object} currentConfig - 当前配置 - * @param {Object} providerPoolManager - 提供商池管理器 - * @returns {Promise} 提供商用量信息 - */ -async function getProviderTypeUsage(providerType, currentConfig, providerPoolManager) { - const result = { - providerType, - instances: [], - totalCount: 0, - successCount: 0, - errorCount: 0 - }; - - // 获取提供商池中的所有实例 - let providers = []; - if (providerPoolManager && providerPoolManager.providerPools && providerPoolManager.providerPools[providerType]) { - providers = providerPoolManager.providerPools[providerType]; - } else if (currentConfig.providerPools && currentConfig.providerPools[providerType]) { - providers = currentConfig.providerPools[providerType]; - } - - result.totalCount = providers.length; - - // 遍历所有提供商实例获取用量 - for (const provider of providers) { - const providerKey = providerType + (provider.uuid || ''); - let adapter = serviceInstances[providerKey]; - - const instanceResult = { - uuid: provider.uuid || 'unknown', - name: getProviderDisplayName(provider, providerType), - isHealthy: provider.isHealthy !== false, - isDisabled: provider.isDisabled === true, - success: false, - usage: null, - error: null - }; - - // First check if disabled, skip initialization for disabled providers - if (provider.isDisabled) { - instanceResult.error = 'Provider is disabled'; - result.errorCount++; - } else if (!adapter) { - // Service instance not initialized, try auto-initialization - try { - console.log(`[Usage API] Auto-initializing service adapter for ${providerType}: ${provider.uuid}`); - // Build configuration object - const serviceConfig = { - ...CONFIG, - ...provider, - MODEL_PROVIDER: providerType - }; - adapter = getServiceAdapter(serviceConfig); - } catch (initError) { - console.error(`[Usage API] Failed to initialize adapter for ${providerType}: ${provider.uuid}:`, initError.message); - instanceResult.error = `Service instance initialization failed: ${initError.message}`; - result.errorCount++; - } - } - - // If adapter exists (including just initialized), and no error, try to get usage - if (adapter && !instanceResult.error) { - try { - const usage = await getAdapterUsage(adapter, providerType); - instanceResult.success = true; - instanceResult.usage = usage; - result.successCount++; - } catch (error) { - instanceResult.error = error.message; - result.errorCount++; - } - } - - result.instances.push(instanceResult); - } - - return result; -} - -/** - * 从适配器获取用量信息 - * @param {Object} adapter - 服务适配器 - * @param {string} providerType - 提供商类型 - * @returns {Promise} 用量信息 - */ -async function getAdapterUsage(adapter, providerType) { - if (providerType === 'claude-kiro-oauth') { - if (typeof adapter.getUsageLimits === 'function') { - const rawUsage = await adapter.getUsageLimits(); - return formatKiroUsage(rawUsage); - } else if (adapter.kiroApiService && typeof adapter.kiroApiService.getUsageLimits === 'function') { - const rawUsage = await adapter.kiroApiService.getUsageLimits(); - return formatKiroUsage(rawUsage); - } - throw new Error('This adapter does not support usage query'); - } - - if (providerType === 'gemini-cli-oauth') { - if (typeof adapter.getUsageLimits === 'function') { - const rawUsage = await adapter.getUsageLimits(); - return formatGeminiUsage(rawUsage); - } else if (adapter.geminiApiService && typeof adapter.geminiApiService.getUsageLimits === 'function') { - const rawUsage = await adapter.geminiApiService.getUsageLimits(); - return formatGeminiUsage(rawUsage); - } - throw new Error('This adapter does not support usage query'); - } - - if (providerType === 'gemini-antigravity') { - if (typeof adapter.getUsageLimits === 'function') { - const rawUsage = await adapter.getUsageLimits(); - return formatAntigravityUsage(rawUsage); - } else if (adapter.antigravityApiService && typeof adapter.antigravityApiService.getUsageLimits === 'function') { - const rawUsage = await adapter.antigravityApiService.getUsageLimits(); - return formatAntigravityUsage(rawUsage); - } - throw new Error('This adapter does not support usage query'); - } - - throw new Error(`Unsupported provider type: ${providerType}`); -} - -/** - * 获取提供商显示名称 - * @param {Object} provider - 提供商配置 - * @param {string} providerType - 提供商类型 - * @returns {string} 显示名称 - */ -function getProviderDisplayName(provider, providerType) { - // 优先使用自定义名称 - if (provider.customName) { - return provider.customName; - } - - // 尝试从凭据文件路径提取名称 - const credPathKey = { - 'claude-kiro-oauth': 'KIRO_OAUTH_CREDS_FILE_PATH', - 'gemini-cli-oauth': 'GEMINI_OAUTH_CREDS_FILE_PATH', - 'gemini-antigravity': 'ANTIGRAVITY_OAUTH_CREDS_FILE_PATH', - 'openai-qwen-oauth': 'QWEN_OAUTH_CREDS_FILE_PATH', - 'openai-iflow': 'IFLOW_TOKEN_FILE_PATH' - }[providerType]; - - if (credPathKey && provider[credPathKey]) { - const filePath = provider[credPathKey]; - const fileName = path.basename(filePath); - const dirName = path.basename(path.dirname(filePath)); - return `${dirName}/${fileName}`; - } - - return provider.uuid || 'Unnamed'; -} - -/** - * 比较版本号 - * @param {string} v1 - 版本号1 - * @param {string} v2 - 版本号2 - * @returns {number} 1 if v1 > v2, -1 if v1 < v2, 0 if equal - */ -function compareVersions(v1, v2) { - // 移除 'v' 前缀(如果有) - const clean1 = v1.replace(/^v/, ''); - const clean2 = v2.replace(/^v/, ''); - - const parts1 = clean1.split('.').map(Number); - const parts2 = clean2.split('.').map(Number); - - const maxLen = Math.max(parts1.length, parts2.length); - - for (let i = 0; i < maxLen; i++) { - const num1 = parts1[i] || 0; - const num2 = parts2[i] || 0; - - if (num1 > num2) return 1; - if (num1 < num2) return -1; - } - - return 0; -} - -/** - * 通过 GitHub API 获取最新版本 - * @returns {Promise} 最新版本号或 null - */ -async function getLatestVersionFromGitHub() { - const GITHUB_REPO = 'justlovemaki/AIClient-2-API'; - const apiUrl = `https://api.github.com/repos/${GITHUB_REPO}/tags`; - - try { - console.log('[Update] Fetching latest version from GitHub API...'); - const response = await fetch(apiUrl, { - headers: { - 'Accept': 'application/vnd.github.v3+json', - 'User-Agent': 'AIClient2API-UpdateChecker' - }, - timeout: 10000 - }); - - if (!response.ok) { - throw new Error(`GitHub API returned ${response.status}: ${response.statusText}`); - } - - const tags = await response.json(); - - if (!Array.isArray(tags) || tags.length === 0) { - return null; - } - - // 提取版本号并排序 - const versions = tags - .map(tag => tag.name) - .filter(name => /^v?\d+\.\d+/.test(name)); // 只保留符合版本号格式的 tag - - if (versions.length === 0) { - return null; - } - - // 按版本号排序(降序) - versions.sort((a, b) => compareVersions(b, a)); - - return versions[0]; - } catch (error) { - console.warn('[Update] Failed to fetch from GitHub API:', error.message); - return null; - } -} - -/** - * 检查是否有新版本可用 - * 支持两种模式: - * 1. Git 仓库模式:通过 git 命令获取最新 tag - * 2. Docker/非 Git 模式:通过 GitHub API 获取最新版本 - * @returns {Promise} 更新信息 - */ -async function checkForUpdates() { - const versionFilePath = path.join(process.cwd(), 'VERSION'); - - // 读取本地版本 - let localVersion = 'unknown'; - try { - if (existsSync(versionFilePath)) { - localVersion = readFileSync(versionFilePath, 'utf8').trim(); - } - } catch (error) { - console.warn('[Update] Failed to read local VERSION file:', error.message); - } - - // 检查是否在 git 仓库中 - let isGitRepo = false; - try { - await execAsync('git rev-parse --git-dir'); - isGitRepo = true; - } catch (error) { - isGitRepo = false; - console.log('[Update] Not in a Git repository, will use GitHub API to check for updates'); - } - - let latestTag = null; - let updateMethod = 'unknown'; - - if (isGitRepo) { - // Git 仓库模式:使用 git 命令 - updateMethod = 'git'; - - // 获取远程 tags - try { - console.log('[Update] Fetching remote tags...'); - await execAsync('git fetch --tags'); - } catch (error) { - console.warn('[Update] Failed to fetch tags via git, falling back to GitHub API:', error.message); - // 如果 git fetch 失败,回退到 GitHub API - latestTag = await getLatestVersionFromGitHub(); - updateMethod = 'github_api'; - } - - // 如果 git fetch 成功,获取最新的 tag - if (!latestTag && updateMethod === 'git') { - const isWindows = process.platform === 'win32'; - - try { - if (isWindows) { - // Windows: 使用 git for-each-ref,这是跨平台兼容的方式 - const { stdout } = await execAsync('git for-each-ref --sort=-v:refname --format="%(refname:short)" refs/tags --count=1'); - latestTag = stdout.trim(); - } else { - // Linux/macOS: 使用 head 命令,更高效 - const { stdout } = await execAsync('git tag --sort=-v:refname | head -n 1'); - latestTag = stdout.trim(); - } - } catch (error) { - // 备用方案:获取所有 tags 并在 JavaScript 中排序 - try { - const { stdout } = await execAsync('git tag'); - const tags = stdout.trim().split('\n').filter(t => t); - if (tags.length > 0) { - // 按版本号排序(降序) - tags.sort((a, b) => compareVersions(b, a)); - latestTag = tags[0]; - } - } catch (e) { - console.warn('[Update] Failed to get latest tag via git, falling back to GitHub API:', e.message); - latestTag = await getLatestVersionFromGitHub(); - updateMethod = 'github_api'; - } - } - } - } else { - // 非 Git 仓库模式(如 Docker 容器):使用 GitHub API - updateMethod = 'github_api'; - latestTag = await getLatestVersionFromGitHub(); - } - - if (!latestTag) { - return { - hasUpdate: false, - localVersion, - latestVersion: null, - updateMethod, - error: 'Unable to get latest version information' - }; - } - - // 比较版本 - const comparison = compareVersions(latestTag, localVersion); - const hasUpdate = comparison > 0; - - console.log(`[Update] Local version: ${localVersion}, Latest version: ${latestTag}, Has update: ${hasUpdate}, Method: ${updateMethod}`); - - return { - hasUpdate, - localVersion, - latestVersion: latestTag, - updateMethod, - error: null - }; -} - -/** - * 执行更新操作 - * @returns {Promise} 更新结果 - */ -async function performUpdate() { - // 首先检查是否有更新 - const updateInfo = await checkForUpdates(); - - if (updateInfo.error) { - throw new Error(updateInfo.error); - } - - if (!updateInfo.hasUpdate) { - return { - success: true, - message: 'Already at the latest version', - localVersion: updateInfo.localVersion, - latestVersion: updateInfo.latestVersion, - updated: false - }; - } - - const latestTag = updateInfo.latestVersion; - - // 检查更新方式 - 如果是通过 GitHub API 获取的版本信息,说明不在 Git 仓库中 - if (updateInfo.updateMethod === 'github_api') { - // Docker/非 Git 环境,通过下载 tarball 更新 - console.log('[Update] Running in Docker/non-Git environment, will download and extract tarball'); - return await performTarballUpdate(updateInfo.localVersion, latestTag); - } - - console.log(`[Update] Starting update to ${latestTag}...`); - - // 检查是否有未提交的更改 - try { - const { stdout: statusOutput } = await execAsync('git status --porcelain'); - if (statusOutput.trim()) { - // 有未提交的更改,先 stash - console.log('[Update] Stashing local changes...'); - await execAsync('git stash'); - } - } catch (error) { - console.warn('[Update] Failed to check git status:', error.message); - } - - // 执行 checkout 到最新 tag - try { - console.log(`[Update] Checking out to ${latestTag}...`); - await execAsync(`git checkout ${latestTag}`); - } catch (error) { - console.error('[Update] Failed to checkout:', error.message); - throw new Error('Failed to switch to new version: ' + error.message); - } - - // 更新 VERSION 文件(如果 tag 和 VERSION 文件不同步) - const versionFilePath = path.join(process.cwd(), 'VERSION'); - try { - const newVersion = latestTag.replace(/^v/, ''); - writeFileSync(versionFilePath, newVersion, 'utf8'); - console.log(`[Update] VERSION file updated to ${newVersion}`); - } catch (error) { - console.warn('[Update] Failed to update VERSION file:', error.message); - } - - // 检查是否需要安装依赖 - let needsRestart = false; - try { - // 确保本地版本号有 v 前缀,以匹配 git tag 格式 - const localVersionTag = updateInfo.localVersion.startsWith('v') ? updateInfo.localVersion : `v${updateInfo.localVersion}`; - const { stdout: diffOutput } = await execAsync(`git diff ${localVersionTag}..${latestTag} --name-only`); - if (diffOutput.includes('package.json') || diffOutput.includes('package-lock.json')) { - console.log('[Update] package.json changed, running npm install...'); - await execAsync('npm install'); - needsRestart = true; - } - } catch (error) { - console.warn('[Update] Failed to check package changes:', error.message); - } - - console.log(`[Update] Update completed successfully to ${latestTag}`); - - return { - success: true, - message: `Successfully updated to version ${latestTag}`, - localVersion: updateInfo.localVersion, - latestVersion: latestTag, - updated: true, - updateMethod: 'git', - needsRestart: needsRestart, - restartMessage: needsRestart ? 'Dependencies updated, recommend restarting service to apply changes' : null - }; -} - -/** - * 通过下载 tarball 执行更新(用于 Docker/非 Git 环境) - * @param {string} localVersion - 本地版本 - * @param {string} latestTag - 最新版本 tag - * @returns {Promise} 更新结果 - */ -async function performTarballUpdate(localVersion, latestTag) { - const GITHUB_REPO = 'justlovemaki/AIClient-2-API'; - const tarballUrl = `https://github.com/${GITHUB_REPO}/archive/refs/tags/${latestTag}.tar.gz`; - const appDir = process.cwd(); - const tempDir = path.join(appDir, '.update_temp'); - const tarballPath = path.join(tempDir, 'update.tar.gz'); - - console.log(`[Update] Starting tarball update to ${latestTag}...`); - console.log(`[Update] Download URL: ${tarballUrl}`); - - try { - // 1. 创建临时目录 - await fs.mkdir(tempDir, { recursive: true }); - console.log('[Update] Created temp directory'); - - // 2. 下载 tarball - console.log('[Update] Downloading tarball...'); - const response = await fetch(tarballUrl, { - headers: { - 'User-Agent': 'AIClient2API-Updater' - }, - redirect: 'follow' - }); - - if (!response.ok) { - throw new Error(`Failed to download tarball: ${response.status} ${response.statusText}`); - } - - const arrayBuffer = await response.arrayBuffer(); - const buffer = Buffer.from(arrayBuffer); - await fs.writeFile(tarballPath, buffer); - console.log(`[Update] Downloaded tarball (${buffer.length} bytes)`); - - // 3. 解压 tarball - console.log('[Update] Extracting tarball...'); - await execAsync(`tar -xzf "${tarballPath}" -C "${tempDir}"`); - - // 4. 找到解压后的目录(格式通常是 repo-name-tag) - const extractedItems = await fs.readdir(tempDir); - const extractedDir = extractedItems.find(item => - item.startsWith('AIClient-2-API-') || item.startsWith('AIClient2API-') - ); - - if (!extractedDir) { - throw new Error('Could not find extracted directory'); - } - - const sourcePath = path.join(tempDir, extractedDir); - console.log(`[Update] Extracted to: ${sourcePath}`); - - // 5. 备份当前的 package.json 用于比较 - const oldPackageJson = existsSync(path.join(appDir, 'package.json')) - ? readFileSync(path.join(appDir, 'package.json'), 'utf8') - : null; - - // 6. 定义需要保留的目录和文件(不被覆盖) - const preservePaths = [ - 'configs', // 用户配置目录 - 'node_modules', // 依赖目录 - '.update_temp', // 临时更新目录 - 'logs' // 日志目录 - ]; - - // 7. 复制新文件到应用目录 - console.log('[Update] Copying new files...'); - const sourceItems = await fs.readdir(sourcePath); - - for (const item of sourceItems) { - // 跳过需要保留的目录 - if (preservePaths.includes(item)) { - console.log(`[Update] Skipping preserved path: ${item}`); - continue; - } - - const srcItemPath = path.join(sourcePath, item); - const destItemPath = path.join(appDir, item); - - // 删除旧文件/目录(如果存在) - if (existsSync(destItemPath)) { - const stat = await fs.stat(destItemPath); - if (stat.isDirectory()) { - await fs.rm(destItemPath, { recursive: true, force: true }); - } else { - await fs.unlink(destItemPath); - } - } - - // 复制新文件/目录 - await copyRecursive(srcItemPath, destItemPath); - console.log(`[Update] Copied: ${item}`); - } - - // 8. 检查是否需要更新依赖 - let needsRestart = true; // tarball 更新后总是建议重启 - let needsNpmInstall = false; - - if (oldPackageJson) { - const newPackageJson = readFileSync(path.join(appDir, 'package.json'), 'utf8'); - if (oldPackageJson !== newPackageJson) { - console.log('[Update] package.json changed, running npm install...'); - needsNpmInstall = true; - try { - await execAsync('npm install', { cwd: appDir }); - console.log('[Update] npm install completed'); - } catch (npmError) { - console.error('[Update] npm install failed:', npmError.message); - // 不抛出错误,继续更新流程 - } - } - } - - // 9. 清理临时目录 - console.log('[Update] Cleaning up...'); - await fs.rm(tempDir, { recursive: true, force: true }); - - console.log(`[Update] Tarball update completed successfully to ${latestTag}`); - - return { - success: true, - message: `Successfully updated to version ${latestTag}`, - localVersion: localVersion, - latestVersion: latestTag, - updated: true, - updateMethod: 'tarball', - needsRestart: needsRestart, - needsNpmInstall: needsNpmInstall, - restartMessage: 'Code updated, please restart the service to apply changes' - }; - - } catch (error) { - // 清理临时目录 - try { - if (existsSync(tempDir)) { - await fs.rm(tempDir, { recursive: true, force: true }); - } - } catch (cleanupError) { - console.warn('[Update] Failed to cleanup temp directory:', cleanupError.message); - } - - console.error('[Update] Tarball update failed:', error.message); - throw new Error(`Tarball update failed: ${error.message}`); - } -} - -/** - * 递归复制文件或目录 - * @param {string} src - 源路径 - * @param {string} dest - 目标路径 - */ -async function copyRecursive(src, dest) { - const stat = await fs.stat(src); - - if (stat.isDirectory()) { - await fs.mkdir(dest, { recursive: true }); - const items = await fs.readdir(src); - for (const item of items) { - await copyRecursive(path.join(src, item), path.join(dest, item)); - } - } else { - await fs.copyFile(src, dest); - } -} - -/** - * 处理 OAuth 凭据文件上传 - * @param {http.IncomingMessage} req - HTTP 请求对象 - * @param {http.ServerResponse} res - HTTP 响应对象 - * @param {Object} options - 可选配置 - * @param {Object} options.providerMap - 提供商类型映射表 - * @param {string} options.logPrefix - 日志前缀 - * @param {string} options.userInfo - 用户信息(用于日志) - * @param {Object} options.customUpload - 自定义 multer 实例 - * @returns {Promise} 始终返回 true 表示请求已处理 - */ -export function handleUploadOAuthCredentials(req, res, options = {}) { - const { - providerMap = {}, - logPrefix = '[UI API]', - userInfo = '', - customUpload = null - } = options; - - const uploadMiddleware = customUpload ? customUpload.single('file') : upload.single('file'); - - return new Promise((resolve) => { - uploadMiddleware(req, res, async (err) => { - if (err) { - console.error(`${logPrefix} File upload error:`, err.message); - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: err.message || 'File upload failed' - } - })); - resolve(true); - return; - } - - try { - if (!req.file) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: 'No file was uploaded' - } - })); - resolve(true); - return; - } - - // multer执行完成后,表单字段已解析到req.body中 - const providerType = req.body.provider || 'common'; - // 应用提供商映射(如果有) - const provider = providerMap[providerType] || providerType; - const tempFilePath = req.file.path; - - // 根据实际的provider移动文件到正确的目录 - let targetDir = path.join(process.cwd(), 'configs', provider); - - // 如果是kiro类型的凭证,需要再包裹一层文件夹 - if (provider === 'kiro') { - // 使用时间戳作为子文件夹名称,确保每个上传的文件都有独立的目录 - const timestamp = Date.now(); - const originalNameWithoutExt = path.parse(req.file.originalname).name; - const subFolder = `${timestamp}_${originalNameWithoutExt}`; - targetDir = path.join(targetDir, subFolder); - } - - await fs.mkdir(targetDir, { recursive: true }); - - const targetFilePath = path.join(targetDir, req.file.filename); - await fs.rename(tempFilePath, targetFilePath); - - const relativePath = path.relative(process.cwd(), targetFilePath); - - // 广播更新事件 - broadcastEvent('config_update', { - action: 'add', - filePath: relativePath, - provider: provider, - timestamp: new Date().toISOString() - }); - - const userInfoStr = userInfo ? `, ${userInfo}` : ''; - console.log(`${logPrefix} OAuth credentials file uploaded: ${targetFilePath} (provider: ${provider}${userInfoStr})`); - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: true, - message: 'File uploaded successfully', - filePath: relativePath, - originalName: req.file.originalname, - provider: provider - })); - resolve(true); - - } catch (error) { - console.error(`${logPrefix} File upload processing error:`, error); - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: 'File upload processing failed: ' + error.message - } - })); - resolve(true); - } - }); - }); -} +} \ No newline at end of file diff --git a/src/ui-modules/auth.js b/src/ui-modules/auth.js new file mode 100644 index 0000000..9c031dc --- /dev/null +++ b/src/ui-modules/auth.js @@ -0,0 +1,259 @@ +import { existsSync } from 'fs'; +import { promises as fs } from 'fs'; +import path from 'path'; +import crypto from 'crypto'; + +// Token存储到本地文件中 +const TOKEN_STORE_FILE = path.join(process.cwd(), 'configs', 'token-store.json'); + +/** + * 默认密码(当pwd文件不存在时使用) + */ +const DEFAULT_PASSWORD = 'admin123'; + +/** + * 读取密码文件内容 + * 如果文件不存在或读取失败,返回默认密码 + */ +export async function readPasswordFile() { + const pwdFilePath = path.join(process.cwd(), 'configs', 'pwd'); + try { + // 使用异步方式检查文件是否存在并读取,避免竞态条件 + const password = await fs.readFile(pwdFilePath, 'utf8'); + const trimmedPassword = password.trim(); + // 如果密码文件为空,使用默认密码 + if (!trimmedPassword) { + console.log('[Auth] Password file is empty, using default password: ' + DEFAULT_PASSWORD); + return DEFAULT_PASSWORD; + } + console.log('[Auth] Successfully read password file'); + return trimmedPassword; + } catch (error) { + // ENOENT means file does not exist, which is normal + if (error.code === 'ENOENT') { + console.log('[Auth] Password file does not exist, using default password: ' + DEFAULT_PASSWORD); + } else { + console.error('[Auth] Failed to read password file:', error.code || error.message); + console.log('[Auth] Using default password: ' + DEFAULT_PASSWORD); + } + return DEFAULT_PASSWORD; + } +} + +/** + * 验证登录凭据 + */ +export async function validateCredentials(password) { + const storedPassword = await readPasswordFile(); + console.log('[Auth] Validating password, stored password length:', storedPassword ? storedPassword.length : 0, ', input password length:', password ? password.length : 0); + const isValid = storedPassword && password === storedPassword; + console.log('[Auth] Password validation result:', isValid); + return isValid; +} + +/** + * 解析请求体JSON + */ +function parseRequestBody(req) { + return new Promise((resolve, reject) => { + let body = ''; + req.on('data', chunk => { + body += chunk.toString(); + }); + req.on('end', () => { + try { + if (!body.trim()) { + resolve({}); + } else { + resolve(JSON.parse(body)); + } + } catch (error) { + reject(new Error('Invalid JSON format')); + } + }); + req.on('error', reject); + }); +} + +/** + * 生成简单的token + */ +function generateToken() { + return crypto.randomBytes(32).toString('hex'); +} + +/** + * 生成token过期时间 + */ +function getExpiryTime() { + const now = Date.now(); + const expiry = 60 * 60 * 1000; // 1小时 + return now + expiry; +} + +/** + * 读取token存储文件 + */ +async function readTokenStore() { + try { + if (existsSync(TOKEN_STORE_FILE)) { + const content = await fs.readFile(TOKEN_STORE_FILE, 'utf8'); + return JSON.parse(content); + } else { + // 如果文件不存在,创建一个默认的token store + await writeTokenStore({ tokens: {} }); + return { tokens: {} }; + } + } catch (error) { + console.error('[Token Store] Failed to read token store file:', error); + return { tokens: {} }; + } +} + +/** + * 写入token存储文件 + */ +async function writeTokenStore(tokenStore) { + try { + await fs.writeFile(TOKEN_STORE_FILE, JSON.stringify(tokenStore, null, 2), 'utf8'); + } catch (error) { + console.error('[Token Store] Failed to write token store file:', error); + } +} + +/** + * 验证简单token + */ +export async function verifyToken(token) { + const tokenStore = await readTokenStore(); + const tokenInfo = tokenStore.tokens[token]; + if (!tokenInfo) { + return null; + } + + // 检查是否过期 + if (Date.now() > tokenInfo.expiryTime) { + await deleteToken(token); + return null; + } + + return tokenInfo; +} + +/** + * 保存token到本地文件 + */ +async function saveToken(token, tokenInfo) { + const tokenStore = await readTokenStore(); + tokenStore.tokens[token] = tokenInfo; + await writeTokenStore(tokenStore); +} + +/** + * 删除token + */ +async function deleteToken(token) { + const tokenStore = await readTokenStore(); + if (tokenStore.tokens[token]) { + delete tokenStore.tokens[token]; + await writeTokenStore(tokenStore); + } +} + +/** + * 清理过期的token + */ +export async function cleanupExpiredTokens() { + const tokenStore = await readTokenStore(); + const now = Date.now(); + let hasChanges = false; + + for (const token in tokenStore.tokens) { + if (now > tokenStore.tokens[token].expiryTime) { + delete tokenStore.tokens[token]; + hasChanges = true; + } + } + + if (hasChanges) { + await writeTokenStore(tokenStore); + } +} + +/** + * 检查token验证 + */ +export async function checkAuth(req) { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return false; + } + + const token = authHeader.substring(7); + const tokenInfo = await verifyToken(token); + + return tokenInfo !== null; +} + +/** + * 处理登录请求 + */ +export async function handleLoginRequest(req, res) { + if (req.method !== 'POST') { + res.writeHead(405, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, message: 'Only POST requests are supported' })); + return true; + } + + try { + const requestData = await parseRequestBody(req); + const { password } = requestData; + + if (!password) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, message: 'Password cannot be empty' })); + return true; + } + + const isValid = await validateCredentials(password); + + if (isValid) { + // Generate simple token + const token = generateToken(); + const expiryTime = getExpiryTime(); + + // Store token info to local file + await saveToken(token, { + username: 'admin', + loginTime: Date.now(), + expiryTime + }); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: true, + message: 'Login successful', + token, + expiresIn: '1 hour' + })); + } else { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: false, + message: 'Incorrect password, please try again' + })); + } + } catch (error) { + console.error('[Auth] Login processing error:', error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: false, + message: error.message || 'Server error' + })); + } + return true; +} + +// 定时清理过期token +setInterval(cleanupExpiredTokens, 5 * 60 * 1000); // 每5分钟清理一次 \ No newline at end of file diff --git a/src/ui-modules/config-api.js b/src/ui-modules/config-api.js new file mode 100644 index 0000000..f07e5d4 --- /dev/null +++ b/src/ui-modules/config-api.js @@ -0,0 +1,262 @@ +import { existsSync, readFileSync, writeFileSync } from 'fs'; +import { promises as fs } from 'fs'; +import path from 'path'; +import { CONFIG } from '../config-manager.js'; +import { serviceInstances } from '../adapter.js'; +import { initApiService } from '../service-manager.js'; +import { getRequestBody } from '../common.js'; +import { broadcastEvent } from './event-broadcast.js'; + +/** + * 重载配置文件 + * 动态导入config-manager并重新初始化配置 + * @returns {Promise} 返回重载后的配置对象 + */ +export async function reloadConfig(providerPoolManager) { + try { + // Import config manager dynamically + const { initializeConfig } = await import('../config-manager.js'); + + // Reload main config + const newConfig = await initializeConfig(process.argv.slice(2), 'configs/config.json'); + // Update provider pool manager if available + if (providerPoolManager) { + providerPoolManager.providerPools = newConfig.providerPools; + providerPoolManager.initializeProviderStatus(); + } + + // Update global CONFIG + Object.assign(CONFIG, newConfig); + console.log('[UI API] Configuration reloaded:'); + + // Update initApiService - 清空并重新初始化服务实例 + Object.keys(serviceInstances).forEach(key => delete serviceInstances[key]); + initApiService(CONFIG); + + console.log('[UI API] Configuration reloaded successfully'); + + return newConfig; + } catch (error) { + console.error('[UI API] Failed to reload configuration:', error); + throw error; + } +} + +/** + * 获取配置 + */ +export async function handleGetConfig(req, res, currentConfig) { + let systemPrompt = ''; + + if (currentConfig.SYSTEM_PROMPT_FILE_PATH && existsSync(currentConfig.SYSTEM_PROMPT_FILE_PATH)) { + try { + systemPrompt = readFileSync(currentConfig.SYSTEM_PROMPT_FILE_PATH, 'utf-8'); + } catch (e) { + console.warn('[UI API] Failed to read system prompt file:', e.message); + } + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + ...currentConfig, + systemPrompt + })); + return true; +} + +/** + * 更新配置 + */ +export async function handleUpdateConfig(req, res, currentConfig) { + try { + const body = await getRequestBody(req); + const newConfig = body; + + // Update config values in memory + if (newConfig.REQUIRED_API_KEY !== undefined) currentConfig.REQUIRED_API_KEY = newConfig.REQUIRED_API_KEY; + 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.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; + if (newConfig.PROMPT_LOG_MODE !== undefined) currentConfig.PROMPT_LOG_MODE = newConfig.PROMPT_LOG_MODE; + if (newConfig.REQUEST_MAX_RETRIES !== undefined) currentConfig.REQUEST_MAX_RETRIES = newConfig.REQUEST_MAX_RETRIES; + if (newConfig.REQUEST_BASE_DELAY !== undefined) currentConfig.REQUEST_BASE_DELAY = newConfig.REQUEST_BASE_DELAY; + if (newConfig.CRON_NEAR_MINUTES !== undefined) currentConfig.CRON_NEAR_MINUTES = newConfig.CRON_NEAR_MINUTES; + if (newConfig.CRON_REFRESH_TOKEN !== undefined) currentConfig.CRON_REFRESH_TOKEN = newConfig.CRON_REFRESH_TOKEN; + if (newConfig.PROVIDER_POOLS_FILE_PATH !== undefined) currentConfig.PROVIDER_POOLS_FILE_PATH = newConfig.PROVIDER_POOLS_FILE_PATH; + if (newConfig.MAX_ERROR_COUNT !== undefined) currentConfig.MAX_ERROR_COUNT = newConfig.MAX_ERROR_COUNT; + if (newConfig.providerFallbackChain !== undefined) currentConfig.providerFallbackChain = newConfig.providerFallbackChain; + if (newConfig.modelFallbackMapping !== undefined) currentConfig.modelFallbackMapping = newConfig.modelFallbackMapping; + + // Proxy settings + if (newConfig.PROXY_URL !== undefined) currentConfig.PROXY_URL = newConfig.PROXY_URL; + if (newConfig.PROXY_ENABLED_PROVIDERS !== undefined) currentConfig.PROXY_ENABLED_PROVIDERS = newConfig.PROXY_ENABLED_PROVIDERS; + + // Handle system prompt update + if (newConfig.systemPrompt !== undefined) { + const promptPath = currentConfig.SYSTEM_PROMPT_FILE_PATH || 'configs/input_system_prompt.txt'; + try { + const relativePath = path.relative(process.cwd(), promptPath); + writeFileSync(promptPath, newConfig.systemPrompt, 'utf-8'); + + // 广播更新事件 + broadcastEvent('config_update', { + action: 'update', + filePath: relativePath, + type: 'system_prompt', + timestamp: new Date().toISOString() + }); + + console.log('[UI API] System prompt updated'); + } catch (e) { + console.warn('[UI API] Failed to write system prompt:', e.message); + } + } + + // Update config.json file + try { + const configPath = 'configs/config.json'; + + // Create a clean config object for saving (exclude runtime-only properties) + const configToSave = { + REQUIRED_API_KEY: currentConfig.REQUIRED_API_KEY, + SERVER_PORT: currentConfig.SERVER_PORT, + HOST: currentConfig.HOST, + MODEL_PROVIDER: currentConfig.MODEL_PROVIDER, + 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, + PROMPT_LOG_MODE: currentConfig.PROMPT_LOG_MODE, + REQUEST_MAX_RETRIES: currentConfig.REQUEST_MAX_RETRIES, + REQUEST_BASE_DELAY: currentConfig.REQUEST_BASE_DELAY, + CRON_NEAR_MINUTES: currentConfig.CRON_NEAR_MINUTES, + CRON_REFRESH_TOKEN: currentConfig.CRON_REFRESH_TOKEN, + PROVIDER_POOLS_FILE_PATH: currentConfig.PROVIDER_POOLS_FILE_PATH, + MAX_ERROR_COUNT: currentConfig.MAX_ERROR_COUNT, + providerFallbackChain: currentConfig.providerFallbackChain, + modelFallbackMapping: currentConfig.modelFallbackMapping, + PROXY_URL: currentConfig.PROXY_URL, + PROXY_ENABLED_PROVIDERS: currentConfig.PROXY_ENABLED_PROVIDERS + }; + + writeFileSync(configPath, JSON.stringify(configToSave, null, 2), 'utf-8'); + console.log('[UI API] Configuration saved to configs/config.json'); + + // 广播更新事件 + broadcastEvent('config_update', { + action: 'update', + filePath: 'configs/config.json', + type: 'main_config', + timestamp: new Date().toISOString() + }); + } catch (error) { + console.error('[UI API] Failed to save configuration to file:', error.message); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: 'Failed to save configuration to file: ' + error.message, + partial: true // Indicate that memory config was updated but not saved + } + })); + return true; + } + + // Update the global CONFIG object to reflect changes immediately + Object.assign(CONFIG, currentConfig); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: true, + message: 'Configuration updated successfully', + details: 'Configuration has been updated in both memory and config.json file' + })); + return true; + } catch (error) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: error.message } })); + return true; + } +} + +/** + * 重载配置文件 + */ +export async function handleReloadConfig(req, res, providerPoolManager) { + try { + // 调用重载配置函数 + const newConfig = await reloadConfig(providerPoolManager); + + // 广播更新事件 + broadcastEvent('config_update', { + action: 'reload', + filePath: 'configs/config.json', + providerPoolsPath: newConfig.PROVIDER_POOLS_FILE_PATH || null, + timestamp: new Date().toISOString() + }); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: true, + message: 'Configuration files reloaded successfully', + details: { + configReloaded: true, + configPath: 'configs/config.json', + providerPoolsPath: newConfig.PROVIDER_POOLS_FILE_PATH || null + } + })); + return true; + } catch (error) { + console.error('[UI API] Failed to reload config files:', error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: 'Failed to reload configuration files: ' + error.message + } + })); + return true; + } +} + +/** + * 更新管理员密码 + */ +export async function handleUpdateAdminPassword(req, res) { + try { + const body = await getRequestBody(req); + const { password } = body; + + if (!password || password.trim() === '') { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: 'Password cannot be empty' + } + })); + return true; + } + + // 写入密码到 pwd 文件 + const pwdFilePath = path.join(process.cwd(), 'configs', 'pwd'); + await fs.writeFile(pwdFilePath, password.trim(), 'utf-8'); + + console.log('[UI API] Admin password updated successfully'); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: true, + message: 'Admin password updated successfully' + })); + return true; + } catch (error) { + console.error('[UI API] Failed to update admin password:', error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: 'Failed to update password: ' + error.message + } + })); + return true; + } +} \ No newline at end of file diff --git a/src/ui-modules/config-scanner.js b/src/ui-modules/config-scanner.js new file mode 100644 index 0000000..97cf8b7 --- /dev/null +++ b/src/ui-modules/config-scanner.js @@ -0,0 +1,343 @@ +import { existsSync } from 'fs'; +import { promises as fs } from 'fs'; +import path from 'path'; +import { addToUsedPaths, isPathUsed, pathsEqual } from '../provider-utils.js'; + +/** + * 扫描和分析配置文件 + * @param {Object} currentConfig - The current configuration object + * @param {Object} providerPoolManager - Provider pool manager instance + * @returns {Promise} Array of configuration file objects + */ +export async function scanConfigFiles(currentConfig, providerPoolManager) { + const configFiles = []; + + // 只扫描configs目录 + const configsPath = path.join(process.cwd(), 'configs'); + + if (!existsSync(configsPath)) { + // console.log('[Config Scanner] configs directory not found, creating empty result'); + return configFiles; + } + + const usedPaths = new Set(); // 存储已使用的路径,用于判断关联状态 + + // 从配置中提取所有OAuth凭据文件路径 - 标准化路径格式 + addToUsedPaths(usedPaths, currentConfig.GEMINI_OAUTH_CREDS_FILE_PATH); + addToUsedPaths(usedPaths, currentConfig.KIRO_OAUTH_CREDS_FILE_PATH); + addToUsedPaths(usedPaths, currentConfig.QWEN_OAUTH_CREDS_FILE_PATH); + addToUsedPaths(usedPaths, currentConfig.ANTIGRAVITY_OAUTH_CREDS_FILE_PATH); + addToUsedPaths(usedPaths, currentConfig.IFLOW_TOKEN_FILE_PATH); + + // 使用最新的提供商池数据 + let providerPools = currentConfig.providerPools; + if (providerPoolManager && providerPoolManager.providerPools) { + providerPools = providerPoolManager.providerPools; + } + + // 检查提供商池文件中的所有OAuth凭据路径 - 标准化路径格式 + if (providerPools) { + for (const [providerType, providers] of Object.entries(providerPools)) { + for (const provider of providers) { + addToUsedPaths(usedPaths, provider.GEMINI_OAUTH_CREDS_FILE_PATH); + addToUsedPaths(usedPaths, provider.KIRO_OAUTH_CREDS_FILE_PATH); + addToUsedPaths(usedPaths, provider.QWEN_OAUTH_CREDS_FILE_PATH); + addToUsedPaths(usedPaths, provider.ANTIGRAVITY_OAUTH_CREDS_FILE_PATH); + addToUsedPaths(usedPaths, provider.IFLOW_TOKEN_FILE_PATH); + } + } + } + + try { + // 扫描configs目录下的所有子目录和文件 + const configsFiles = await scanOAuthDirectory(configsPath, usedPaths, currentConfig); + configFiles.push(...configsFiles); + } catch (error) { + console.warn(`[Config Scanner] Failed to scan configs directory:`, error.message); + } + + return configFiles; +} + +/** + * 分析 OAuth 配置文件并返回元数据 + * @param {string} filePath - Full path to the file + * @param {Set} usedPaths - Set of paths currently in use + * @returns {Promise} OAuth file information object + */ +async function analyzeOAuthFile(filePath, usedPaths, currentConfig) { + try { + const stats = await fs.stat(filePath); + const ext = path.extname(filePath).toLowerCase(); + const filename = path.basename(filePath); + const relativePath = path.relative(process.cwd(), filePath); + + // 读取文件内容进行分析 + let content = ''; + let type = 'oauth_credentials'; + let isValid = true; + let errorMessage = ''; + let oauthProvider = 'unknown'; + let usageInfo = getFileUsageInfo(relativePath, filename, usedPaths, currentConfig); + + try { + if (ext === '.json') { + const rawContent = await fs.readFile(filePath, 'utf8'); + const jsonData = JSON.parse(rawContent); + content = rawContent; + + // 识别OAuth提供商 + if (jsonData.apiKey || jsonData.api_key) { + type = 'api_key'; + } else if (jsonData.client_id || jsonData.client_secret) { + oauthProvider = 'oauth2'; + } else if (jsonData.access_token || jsonData.refresh_token) { + oauthProvider = 'token_based'; + } else if (jsonData.credentials) { + oauthProvider = 'service_account'; + } + + if (jsonData.base_url || jsonData.endpoint) { + if (jsonData.base_url.includes('openai.com')) { + oauthProvider = 'openai'; + } else if (jsonData.base_url.includes('anthropic.com')) { + oauthProvider = 'claude'; + } else if (jsonData.base_url.includes('googleapis.com')) { + oauthProvider = 'gemini'; + } + } + } else { + content = await fs.readFile(filePath, 'utf8'); + + if (ext === '.key' || ext === '.pem') { + if (content.includes('-----BEGIN') && content.includes('PRIVATE KEY-----')) { + oauthProvider = 'private_key'; + } + } else if (ext === '.txt') { + if (content.includes('api_key') || content.includes('apikey')) { + oauthProvider = 'api_key'; + } + } else if (ext === '.oauth' || ext === '.creds') { + oauthProvider = 'oauth_credentials'; + } + } + } catch (readError) { + isValid = false; + errorMessage = `Unable to read file: ${readError.message}`; + } + + return { + name: filename, + path: relativePath, + size: stats.size, + type: type, + provider: oauthProvider, + extension: ext, + modified: stats.mtime.toISOString(), + isValid: isValid, + errorMessage: errorMessage, + isUsed: isPathUsed(relativePath, filename, usedPaths), + usageInfo: usageInfo, // 新增详细关联信息 + preview: content.substring(0, 100) + (content.length > 100 ? '...' : '') + }; + } catch (error) { + console.warn(`[OAuth Analyzer] Failed to analyze file ${filePath}:`, error.message); + return null; + } +} + +/** + * Get detailed usage information for a file + * @param {string} relativePath - Relative file path + * @param {string} fileName - File name + * @param {Set} usedPaths - Set of used paths + * @param {Object} currentConfig - Current configuration + * @returns {Object} Usage information object + */ +function getFileUsageInfo(relativePath, fileName, usedPaths, currentConfig) { + const usageInfo = { + isUsed: false, + usageType: null, + usageDetails: [] + }; + + // 检查是否被使用 + const isUsed = isPathUsed(relativePath, fileName, usedPaths); + if (!isUsed) { + return usageInfo; + } + + usageInfo.isUsed = true; + + // 检查主要配置中的使用情况 + if (currentConfig.GEMINI_OAUTH_CREDS_FILE_PATH && + (pathsEqual(relativePath, currentConfig.GEMINI_OAUTH_CREDS_FILE_PATH) || + pathsEqual(relativePath, currentConfig.GEMINI_OAUTH_CREDS_FILE_PATH.replace(/\\/g, '/')))) { + usageInfo.usageType = 'main_config'; + usageInfo.usageDetails.push({ + type: 'Main Config', + location: 'Gemini OAuth credentials file path', + configKey: 'GEMINI_OAUTH_CREDS_FILE_PATH' + }); + } + + if (currentConfig.KIRO_OAUTH_CREDS_FILE_PATH && + (pathsEqual(relativePath, currentConfig.KIRO_OAUTH_CREDS_FILE_PATH) || + pathsEqual(relativePath, currentConfig.KIRO_OAUTH_CREDS_FILE_PATH.replace(/\\/g, '/')))) { + usageInfo.usageType = 'main_config'; + usageInfo.usageDetails.push({ + type: 'Main Config', + location: 'Kiro OAuth credentials file path', + configKey: 'KIRO_OAUTH_CREDS_FILE_PATH' + }); + } + + if (currentConfig.QWEN_OAUTH_CREDS_FILE_PATH && + (pathsEqual(relativePath, currentConfig.QWEN_OAUTH_CREDS_FILE_PATH) || + pathsEqual(relativePath, currentConfig.QWEN_OAUTH_CREDS_FILE_PATH.replace(/\\/g, '/')))) { + usageInfo.usageType = 'main_config'; + usageInfo.usageDetails.push({ + type: 'Main Config', + location: 'Qwen OAuth credentials file path', + configKey: 'QWEN_OAUTH_CREDS_FILE_PATH' + }); + } + + if (currentConfig.IFLOW_TOKEN_FILE_PATH && + (pathsEqual(relativePath, currentConfig.IFLOW_TOKEN_FILE_PATH) || + pathsEqual(relativePath, currentConfig.IFLOW_TOKEN_FILE_PATH.replace(/\\/g, '/')))) { + usageInfo.usageType = 'main_config'; + usageInfo.usageDetails.push({ + type: 'Main Config', + location: 'iFlow Token file path', + configKey: 'IFLOW_TOKEN_FILE_PATH' + }); + } + + // 检查提供商池中的使用情况 + if (currentConfig.providerPools) { + // 使用 flatMap 将双重循环优化为单层循环 O(n) + const allProviders = Object.entries(currentConfig.providerPools).flatMap( + ([providerType, providers]) => + providers.map((provider, index) => ({ provider, providerType, index })) + ); + + for (const { provider, providerType, index } of allProviders) { + const providerUsages = []; + + if (provider.GEMINI_OAUTH_CREDS_FILE_PATH && + (pathsEqual(relativePath, provider.GEMINI_OAUTH_CREDS_FILE_PATH) || + pathsEqual(relativePath, provider.GEMINI_OAUTH_CREDS_FILE_PATH.replace(/\\/g, '/')))) { + providerUsages.push({ + type: 'Provider Pool', + location: `Gemini OAuth credentials (node ${index + 1})`, + providerType: providerType, + providerIndex: index, + configKey: 'GEMINI_OAUTH_CREDS_FILE_PATH' + }); + } + + if (provider.KIRO_OAUTH_CREDS_FILE_PATH && + (pathsEqual(relativePath, provider.KIRO_OAUTH_CREDS_FILE_PATH) || + pathsEqual(relativePath, provider.KIRO_OAUTH_CREDS_FILE_PATH.replace(/\\/g, '/')))) { + providerUsages.push({ + type: 'Provider Pool', + location: `Kiro OAuth credentials (node ${index + 1})`, + providerType: providerType, + providerIndex: index, + configKey: 'KIRO_OAUTH_CREDS_FILE_PATH' + }); + } + + if (provider.QWEN_OAUTH_CREDS_FILE_PATH && + (pathsEqual(relativePath, provider.QWEN_OAUTH_CREDS_FILE_PATH) || + pathsEqual(relativePath, provider.QWEN_OAUTH_CREDS_FILE_PATH.replace(/\\/g, '/')))) { + providerUsages.push({ + type: 'Provider Pool', + location: `Qwen OAuth credentials (node ${index + 1})`, + providerType: providerType, + providerIndex: index, + configKey: 'QWEN_OAUTH_CREDS_FILE_PATH' + }); + } + + if (provider.ANTIGRAVITY_OAUTH_CREDS_FILE_PATH && + (pathsEqual(relativePath, provider.ANTIGRAVITY_OAUTH_CREDS_FILE_PATH) || + pathsEqual(relativePath, provider.ANTIGRAVITY_OAUTH_CREDS_FILE_PATH.replace(/\\/g, '/')))) { + providerUsages.push({ + type: 'Provider Pool', + location: `Antigravity OAuth credentials (node ${index + 1})`, + providerType: providerType, + providerIndex: index, + configKey: 'ANTIGRAVITY_OAUTH_CREDS_FILE_PATH' + }); + } + + if (provider.IFLOW_TOKEN_FILE_PATH && + (pathsEqual(relativePath, provider.IFLOW_TOKEN_FILE_PATH) || + pathsEqual(relativePath, provider.IFLOW_TOKEN_FILE_PATH.replace(/\\/g, '/')))) { + providerUsages.push({ + type: 'Provider Pool', + location: `iFlow Token (node ${index + 1})`, + providerType: providerType, + providerIndex: index, + configKey: 'IFLOW_TOKEN_FILE_PATH' + }); + } + + if (providerUsages.length > 0) { + usageInfo.usageType = 'provider_pool'; + usageInfo.usageDetails.push(...providerUsages); + } + } + } + + // 如果有多个使用位置,标记为多种用途 + if (usageInfo.usageDetails.length > 1) { + usageInfo.usageType = 'multiple'; + } + + return usageInfo; +} + +/** + * Scan OAuth directory for credential files + * @param {string} dirPath - Directory path to scan + * @param {Set} usedPaths - Set of used paths + * @param {Object} currentConfig - Current configuration + * @returns {Promise} Array of OAuth configuration file objects + */ +async function scanOAuthDirectory(dirPath, usedPaths, currentConfig) { + const oauthFiles = []; + + try { + const files = await fs.readdir(dirPath, { withFileTypes: true }); + + for (const file of files) { + const fullPath = path.join(dirPath, file.name); + + if (file.isFile()) { + const ext = path.extname(file.name).toLowerCase(); + // 只关注OAuth相关的文件类型 + if (['.json', '.oauth', '.creds', '.key', '.pem', '.txt'].includes(ext)) { + const fileInfo = await analyzeOAuthFile(fullPath, usedPaths, currentConfig); + if (fileInfo) { + oauthFiles.push(fileInfo); + } + } + } else if (file.isDirectory()) { + // 递归扫描子目录(限制深度) + const relativePath = path.relative(process.cwd(), fullPath); + // 最大深度4层,以支持 configs/kiro/{subfolder}/file.json 这样的结构 + if (relativePath.split(path.sep).length < 4) { + const subFiles = await scanOAuthDirectory(fullPath, usedPaths, currentConfig); + oauthFiles.push(...subFiles); + } + } + } + } catch (error) { + console.warn(`[OAuth Scanner] Failed to scan directory ${dirPath}:`, error.message); + } + + return oauthFiles; +} \ No newline at end of file diff --git a/src/ui-modules/event-broadcast.js b/src/ui-modules/event-broadcast.js new file mode 100644 index 0000000..1a1c91a --- /dev/null +++ b/src/ui-modules/event-broadcast.js @@ -0,0 +1,269 @@ +import { existsSync, readFileSync } from 'fs'; +import { promises as fs } from 'fs'; +import path from 'path'; +import multer from 'multer'; + +// Token存储到本地文件中 +const TOKEN_STORE_FILE = path.join(process.cwd(), 'configs', 'token-store.json'); + +// 用量缓存文件路径 +const USAGE_CACHE_FILE = path.join(process.cwd(), 'configs', 'usage-cache.json'); + +/** + * Helper function to broadcast events to UI clients + * @param {string} eventType - The type of event + * @param {any} data - The data to broadcast + */ +export function broadcastEvent(eventType, data) { + if (global.eventClients && global.eventClients.length > 0) { + const payload = typeof data === 'string' ? data : JSON.stringify(data); + global.eventClients.forEach(client => { + client.write(`event: ${eventType}\n`); + client.write(`data: ${payload}\n\n`); + }); + } +} + +/** + * Server-Sent Events for real-time updates + */ +export async function handleEvents(req, res) { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'Access-Control-Allow-Origin': '*' + }); + + res.write('\n'); + + // Store the response object for broadcasting + if (!global.eventClients) { + global.eventClients = []; + } + global.eventClients.push(res); + + // Keep connection alive + const keepAlive = setInterval(() => { + res.write(':\n\n'); + }, 30000); + + req.on('close', () => { + clearInterval(keepAlive); + global.eventClients = global.eventClients.filter(r => r !== res); + }); + + return true; +} + +/** + * Initialize UI management features + */ +export function initializeUIManagement() { + // Initialize log broadcasting for UI + if (!global.eventClients) { + global.eventClients = []; + } + if (!global.logBuffer) { + global.logBuffer = []; + } + + // Override console.log to broadcast logs + const originalLog = console.log; + console.log = function(...args) { + originalLog.apply(console, args); + const message = args.map(arg => { + if (typeof arg === 'string') return arg; + try { + return JSON.stringify(arg); + } catch (e) { + if (arg instanceof Error) { + return `[Error: ${arg.message}] ${arg.stack || ''}`; + } + return `[Object: ${Object.prototype.toString.call(arg)}] (Circular or too complex to stringify)`; + } + }).join(' '); + const logEntry = { + timestamp: new Date().toISOString(), + level: 'info', + message: message + }; + global.logBuffer.push(logEntry); + if (global.logBuffer.length > 100) { + global.logBuffer.shift(); + } + broadcastEvent('log', logEntry); + }; + + // Override console.error to broadcast errors + const originalError = console.error; + console.error = function(...args) { + originalError.apply(console, args); + const message = args.map(arg => { + if (typeof arg === 'string') return arg; + try { + return JSON.stringify(arg); + } catch (e) { + if (arg instanceof Error) { + return `[Error: ${arg.message}] ${arg.stack || ''}`; + } + return `[Object: ${Object.prototype.toString.call(arg)}] (Circular or too complex to stringify)`; + } + }).join(' '); + const logEntry = { + timestamp: new Date().toISOString(), + level: 'error', + message: message + }; + global.logBuffer.push(logEntry); + if (global.logBuffer.length > 100) { + global.logBuffer.shift(); + } + broadcastEvent('log', logEntry); + }; +} + +// 配置multer中间件 +const storage = multer.diskStorage({ + destination: async (req, file, cb) => { + try { + // multer在destination回调时req.body还未解析,先使用默认路径 + // 实际的provider会在文件上传完成后从req.body中获取 + const uploadPath = path.join(process.cwd(), 'configs', 'temp'); + await fs.mkdir(uploadPath, { recursive: true }); + cb(null, uploadPath); + } catch (error) { + cb(error); + } + }, + filename: (req, file, cb) => { + const timestamp = Date.now(); + const sanitizedName = file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_'); + cb(null, `${timestamp}_${sanitizedName}`); + } +}); + +const fileFilter = (req, file, cb) => { + const allowedTypes = ['.json', '.txt', '.key', '.pem', '.p12', '.pfx']; + const ext = path.extname(file.originalname).toLowerCase(); + if (allowedTypes.includes(ext)) { + cb(null, true); + } else { + cb(new Error('Unsupported file type'), false); + } +}; + +export const upload = multer({ + storage, + fileFilter, + limits: { + fileSize: 5 * 1024 * 1024 // 5MB限制 + } +}); + +/** + * 处理 OAuth 凭据文件上传 + * @param {http.IncomingMessage} req - HTTP 请求对象 + * @param {http.ServerResponse} res - HTTP 响应对象 + * @param {Object} options - 可选配置 + * @param {Object} options.providerMap - 提供商类型映射表 + * @param {string} options.logPrefix - 日志前缀 + * @param {string} options.userInfo - 用户信息(用于日志) + * @param {Object} options.customUpload - 自定义 multer 实例 + * @returns {Promise} 始终返回 true 表示请求已处理 + */ +export function handleUploadOAuthCredentials(req, res, options = {}) { + const { + providerMap = {}, + logPrefix = '[UI API]', + userInfo = '', + customUpload = null + } = options; + + const uploadMiddleware = customUpload ? customUpload.single('file') : upload.single('file'); + + return new Promise((resolve) => { + uploadMiddleware(req, res, async (err) => { + if (err) { + console.error(`${logPrefix} File upload error:`, err.message); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: err.message || 'File upload failed' + } + })); + resolve(true); + return; + } + + try { + if (!req.file) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: 'No file was uploaded' + } + })); + resolve(true); + return; + } + + // multer执行完成后,表单字段已解析到req.body中 + const providerType = req.body.provider || 'common'; + // 应用提供商映射(如果有) + const provider = providerMap[providerType] || providerType; + const tempFilePath = req.file.path; + + // 根据实际的provider移动文件到正确的目录 + let targetDir = path.join(process.cwd(), 'configs', provider); + + // 如果是kiro类型的凭证,需要再包裹一层文件夹 + if (provider === 'kiro') { + // 使用时间戳作为子文件夹名称,确保每个上传的文件都有独立的目录 + const timestamp = Date.now(); + const originalNameWithoutExt = path.parse(req.file.originalname).name; + const subFolder = `${timestamp}_${originalNameWithoutExt}`; + targetDir = path.join(targetDir, subFolder); + } + + await fs.mkdir(targetDir, { recursive: true }); + + const targetFilePath = path.join(targetDir, req.file.filename); + await fs.rename(tempFilePath, targetFilePath); + + const relativePath = path.relative(process.cwd(), targetFilePath); + + // 广播更新事件 + broadcastEvent('config_update', { + action: 'add', + filePath: relativePath, + provider: provider, + timestamp: new Date().toISOString() + }); + + const userInfoStr = userInfo ? `, ${userInfo}` : ''; + console.log(`${logPrefix} OAuth credentials file uploaded: ${targetFilePath} (provider: ${provider}${userInfoStr})`); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: true, + message: 'File uploaded successfully', + filePath: relativePath, + originalName: req.file.originalname, + provider: provider + })); + resolve(true); + + } catch (error) { + console.error(`${logPrefix} File upload processing error:`, error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: 'File upload processing failed: ' + error.message + } + })); + resolve(true); + } + }); + }); +} \ No newline at end of file diff --git a/src/ui-modules/oauth-api.js b/src/ui-modules/oauth-api.js new file mode 100644 index 0000000..f2de003 --- /dev/null +++ b/src/ui-modules/oauth-api.js @@ -0,0 +1,306 @@ +import { getRequestBody } from '../common.js'; +import { + handleGeminiCliOAuth, + handleGeminiAntigravityOAuth, + handleQwenOAuth, + handleKiroOAuth, + handleIFlowOAuth, + batchImportKiroRefreshTokensStream, + importAwsCredentials +} from '../oauth-handlers.js'; + +/** + * 生成 OAuth 授权 URL + */ +export async function handleGenerateAuthUrl(req, res, currentConfig, providerType) { + try { + let authUrl = ''; + let authInfo = {}; + + // 解析 options + let options = {}; + try { + options = await getRequestBody(req); + } catch (e) { + // 如果没有请求体,使用默认空对象 + } + + // 根据提供商类型生成授权链接并启动回调服务器 + if (providerType === 'gemini-cli-oauth') { + const result = await handleGeminiCliOAuth(currentConfig, options); + authUrl = result.authUrl; + authInfo = result.authInfo; + } else if (providerType === 'gemini-antigravity') { + const result = await handleGeminiAntigravityOAuth(currentConfig, options); + authUrl = result.authUrl; + authInfo = result.authInfo; + } else if (providerType === 'openai-qwen-oauth') { + const result = await handleQwenOAuth(currentConfig, options); + authUrl = result.authUrl; + authInfo = result.authInfo; + } else if (providerType === 'claude-kiro-oauth') { + // Kiro OAuth 支持多种认证方式 + // options.method 可以是: 'google' | 'github' | 'builder-id' + const result = await handleKiroOAuth(currentConfig, options); + authUrl = result.authUrl; + authInfo = result.authInfo; + } else if (providerType === 'openai-iflow') { + // iFlow OAuth 授权 + const result = await handleIFlowOAuth(currentConfig, options); + authUrl = result.authUrl; + authInfo = result.authInfo; + } else { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: `Unsupported provider type: ${providerType}` + } + })); + return true; + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: true, + authUrl: authUrl, + authInfo: authInfo + })); + return true; + + } catch (error) { + console.error(`[UI API] Failed to generate auth URL for ${providerType}:`, error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: `Failed to generate auth URL: ${error.message}` + } + })); + return true; + } +} + +/** + * 处理手动 OAuth 回调 + */ +export async function handleManualOAuthCallback(req, res) { + 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; + } +} + +/** + * 批量导入 Kiro refreshToken(带实时进度 SSE) + */ +export async function handleBatchImportKiroTokens(req, res) { + try { + const body = await getRequestBody(req); + const { refreshTokens, region } = body; + + if (!refreshTokens || !Array.isArray(refreshTokens) || refreshTokens.length === 0) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: false, + error: 'refreshTokens array is required and must not be empty' + })); + return true; + } + + console.log(`[Kiro Batch Import] Starting batch import of ${refreshTokens.length} tokens with SSE...`); + + // 设置 SSE 响应头 + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'X-Accel-Buffering': 'no' + }); + + // 发送 SSE 事件的辅助函数 + const sendSSE = (event, data) => { + res.write(`event: ${event}\n`); + res.write(`data: ${JSON.stringify(data)}\n\n`); + }; + + // 发送开始事件 + sendSSE('start', { total: refreshTokens.length }); + + // 执行流式批量导入 + const result = await batchImportKiroRefreshTokensStream( + refreshTokens, + region || 'us-east-1', + (progress) => { + // 每处理完一个 token 发送进度更新 + sendSSE('progress', progress); + } + ); + + console.log(`[Kiro Batch Import] Completed: ${result.success} success, ${result.failed} failed`); + + // 发送完成事件 + sendSSE('complete', { + success: true, + total: result.total, + successCount: result.success, + failedCount: result.failed, + details: result.details + }); + + res.end(); + return true; + + } catch (error) { + console.error('[Kiro Batch Import] Error:', error); + // 如果已经开始发送 SSE,则发送错误事件 + if (res.headersSent) { + res.write(`event: error\n`); + res.write(`data: ${JSON.stringify({ error: error.message })}\n\n`); + res.end(); + } else { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: false, + error: error.message + })); + } + return true; + } +} + +/** + * 导入 AWS SSO 凭据用于 Kiro + */ +export async function handleImportAwsCredentials(req, res) { + try { + const body = await getRequestBody(req); + const { credentials } = body; + + if (!credentials || typeof credentials !== 'object') { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: false, + error: 'credentials object is required' + })); + return true; + } + + // 验证必需字段 - 需要四个字段都存在 + const missingFields = []; + if (!credentials.clientId) missingFields.push('clientId'); + if (!credentials.clientSecret) missingFields.push('clientSecret'); + if (!credentials.accessToken) missingFields.push('accessToken'); + if (!credentials.refreshToken) missingFields.push('refreshToken'); + + if (missingFields.length > 0) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: false, + error: `Missing required fields: ${missingFields.join(', ')}` + })); + return true; + } + + console.log('[Kiro AWS Import] Starting AWS credentials import...'); + + const result = await importAwsCredentials(credentials); + + if (result.success) { + console.log(`[Kiro AWS Import] Successfully imported credentials to: ${result.path}`); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: true, + path: result.path, + message: 'AWS credentials imported successfully' + })); + } else { + // 重复凭据返回 409 Conflict,其他错误返回 500 + const statusCode = result.error === 'duplicate' ? 409 : 500; + res.writeHead(statusCode, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: false, + error: result.error, + existingPath: result.existingPath || null + })); + } + return true; + + } catch (error) { + console.error('[Kiro AWS Import] Error:', error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: false, + error: error.message + })); + return true; + } +} \ No newline at end of file diff --git a/src/ui-modules/plugin-api.js b/src/ui-modules/plugin-api.js new file mode 100644 index 0000000..a3bf5fc --- /dev/null +++ b/src/ui-modules/plugin-api.js @@ -0,0 +1,76 @@ +import { getPluginManager } from '../plugin-manager.js'; +import { getRequestBody } from '../common.js'; +import { broadcastEvent } from './event-broadcast.js'; + +/** + * 获取插件列表 + */ +export async function handleGetPlugins(req, res) { + try { + const pluginManager = getPluginManager(); + const plugins = pluginManager.getPluginList(); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ plugins })); + return true; + } catch (error) { + console.error('[UI API] Failed to get plugins:', error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: 'Failed to get plugins list: ' + error.message + } + })); + return true; + } +} + +/** + * 切换插件状态 + */ +export async function handleTogglePlugin(req, res, pluginName) { + try { + const body = await getRequestBody(req); + const { enabled } = body; + + if (typeof enabled !== 'boolean') { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: 'Enabled status must be a boolean' + } + })); + return true; + } + + const pluginManager = getPluginManager(); + await pluginManager.setPluginEnabled(pluginName, enabled); + + // 广播更新事件 + broadcastEvent('plugin_update', { + action: 'toggle', + pluginName, + enabled, + timestamp: new Date().toISOString() + }); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: true, + message: `Plugin ${pluginName} ${enabled ? 'enabled' : 'disabled'} successfully`, + plugin: { + name: pluginName, + enabled + } + })); + return true; + } catch (error) { + console.error('[UI API] Failed to toggle plugin:', error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: 'Failed to toggle plugin: ' + error.message + } + })); + return true; + } +} \ No newline at end of file diff --git a/src/ui-modules/provider-api.js b/src/ui-modules/provider-api.js new file mode 100644 index 0000000..9400b17 --- /dev/null +++ b/src/ui-modules/provider-api.js @@ -0,0 +1,707 @@ +import { existsSync, readFileSync, writeFileSync } from 'fs'; +import { getRequestBody } from '../common.js'; +import { getAllProviderModels, getProviderModels } from '../provider-models.js'; +import { generateUUID, createProviderConfig, formatSystemPath, detectProviderFromPath, addToUsedPaths, isPathUsed, pathsEqual } from '../provider-utils.js'; +import { broadcastEvent } from './event-broadcast.js'; + +/** + * 获取提供商池摘要 + */ +export async function handleGetProviders(req, res, currentConfig, providerPoolManager) { + let providerPools = {}; + const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; + try { + if (providerPoolManager && providerPoolManager.providerPools) { + providerPools = providerPoolManager.providerPools; + } else if (filePath && existsSync(filePath)) { + const poolsData = JSON.parse(readFileSync(filePath, 'utf-8')); + providerPools = poolsData; + } + } catch (error) { + console.warn('[UI API] Failed to load provider pools:', error.message); + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(providerPools)); + return true; +} + +/** + * 获取特定提供商类型的详细信息 + */ +export async function handleGetProviderType(req, res, currentConfig, providerPoolManager, providerType) { + let providerPools = {}; + const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; + try { + if (providerPoolManager && providerPoolManager.providerPools) { + providerPools = providerPoolManager.providerPools; + } else if (filePath && existsSync(filePath)) { + const poolsData = JSON.parse(readFileSync(filePath, 'utf-8')); + providerPools = poolsData; + } + } catch (error) { + console.warn('[UI API] Failed to load provider pools:', error.message); + } + + const providers = providerPools[providerType] || []; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + providerType, + providers, + totalCount: providers.length, + healthyCount: providers.filter(p => p.isHealthy).length + })); + return true; +} + +/** + * 获取所有提供商的可用模型 + */ +export async function handleGetProviderModels(req, res) { + const allModels = getAllProviderModels(); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(allModels)); + return true; +} + +/** + * 获取特定提供商类型的可用模型 + */ +export async function handleGetProviderTypeModels(req, res, providerType) { + const models = getProviderModels(providerType); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + providerType, + models + })); + return true; +} + +/** + * 添加新的提供商配置 + */ +export async function handleAddProvider(req, res, currentConfig, providerPoolManager) { + try { + const body = await getRequestBody(req); + const { providerType, providerConfig } = body; + + if (!providerType || !providerConfig) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'providerType and providerConfig are required' } })); + return true; + } + + // Generate UUID if not provided + if (!providerConfig.uuid) { + providerConfig.uuid = generateUUID(); + } + + // Set default values + providerConfig.isHealthy = providerConfig.isHealthy !== undefined ? providerConfig.isHealthy : true; + providerConfig.lastUsed = providerConfig.lastUsed || null; + providerConfig.usageCount = providerConfig.usageCount || 0; + providerConfig.errorCount = providerConfig.errorCount || 0; + providerConfig.lastErrorTime = providerConfig.lastErrorTime || null; + + const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'provider_pools.json'; + let providerPools = {}; + + // Load existing pools + if (existsSync(filePath)) { + try { + const fileContent = readFileSync(filePath, 'utf-8'); + providerPools = JSON.parse(fileContent); + } catch (readError) { + console.warn('[UI API] Failed to read existing provider pools:', readError.message); + } + } + + // Add new provider to the appropriate type + if (!providerPools[providerType]) { + providerPools[providerType] = []; + } + providerPools[providerType].push(providerConfig); + + // Save to file + writeFileSync(filePath, JSON.stringify(providerPools, null, 2), 'utf-8'); + console.log(`[UI API] Added new provider to ${providerType}: ${providerConfig.uuid}`); + + // Update provider pool manager if available + if (providerPoolManager) { + providerPoolManager.providerPools = providerPools; + providerPoolManager.initializeProviderStatus(); + } + + // 广播更新事件 + broadcastEvent('config_update', { + action: 'add', + filePath: filePath, + providerType, + providerConfig, + timestamp: new Date().toISOString() + }); + + // 广播提供商更新事件 + broadcastEvent('provider_update', { + action: 'add', + providerType, + providerConfig, + timestamp: new Date().toISOString() + }); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: true, + message: 'Provider added successfully', + provider: providerConfig, + providerType + })); + return true; + } catch (error) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: error.message } })); + return true; + } +} + +/** + * 更新特定提供商配置 + */ +export async function handleUpdateProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid) { + try { + const body = await getRequestBody(req); + const { providerConfig } = body; + + if (!providerConfig) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'providerConfig is required' } })); + return true; + } + + const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; + let providerPools = {}; + + // Load existing pools + if (existsSync(filePath)) { + try { + const fileContent = readFileSync(filePath, 'utf-8'); + providerPools = JSON.parse(fileContent); + } catch (readError) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'Provider pools file not found' } })); + return true; + } + } + + // Find and update the provider + const providers = providerPools[providerType] || []; + const providerIndex = providers.findIndex(p => p.uuid === providerUuid); + + if (providerIndex === -1) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'Provider not found' } })); + return true; + } + + // Update provider while preserving certain fields + const existingProvider = providers[providerIndex]; + const updatedProvider = { + ...existingProvider, + ...providerConfig, + uuid: providerUuid, // Ensure UUID doesn't change + lastUsed: existingProvider.lastUsed, // Preserve usage stats + usageCount: existingProvider.usageCount, + errorCount: existingProvider.errorCount, + lastErrorTime: existingProvider.lastErrorTime + }; + + providerPools[providerType][providerIndex] = updatedProvider; + + // Save to file + writeFileSync(filePath, JSON.stringify(providerPools, null, 2), 'utf-8'); + console.log(`[UI API] Updated provider ${providerUuid} in ${providerType}`); + + // Update provider pool manager if available + if (providerPoolManager) { + providerPoolManager.providerPools = providerPools; + providerPoolManager.initializeProviderStatus(); + } + + // 广播更新事件 + broadcastEvent('config_update', { + action: 'update', + filePath: filePath, + providerType, + providerConfig: updatedProvider, + timestamp: new Date().toISOString() + }); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: true, + message: 'Provider updated successfully', + provider: updatedProvider + })); + return true; + } catch (error) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: error.message } })); + return true; + } +} + +/** + * 删除特定提供商配置 + */ +export async function handleDeleteProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid) { + try { + const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; + let providerPools = {}; + + // Load existing pools + if (existsSync(filePath)) { + try { + const fileContent = readFileSync(filePath, 'utf-8'); + providerPools = JSON.parse(fileContent); + } catch (readError) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'Provider pools file not found' } })); + return true; + } + } + + // Find and remove the provider + const providers = providerPools[providerType] || []; + const providerIndex = providers.findIndex(p => p.uuid === providerUuid); + + if (providerIndex === -1) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'Provider not found' } })); + return true; + } + + const deletedProvider = providers[providerIndex]; + providers.splice(providerIndex, 1); + + // Remove the entire provider type if no providers left + if (providers.length === 0) { + delete providerPools[providerType]; + } + + // Save to file + writeFileSync(filePath, JSON.stringify(providerPools, null, 2), 'utf-8'); + console.log(`[UI API] Deleted provider ${providerUuid} from ${providerType}`); + + // Update provider pool manager if available + if (providerPoolManager) { + providerPoolManager.providerPools = providerPools; + providerPoolManager.initializeProviderStatus(); + } + + // 广播更新事件 + broadcastEvent('config_update', { + action: 'delete', + filePath: filePath, + providerType, + providerConfig: deletedProvider, + timestamp: new Date().toISOString() + }); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: true, + message: 'Provider deleted successfully', + deletedProvider + })); + return true; + } catch (error) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: error.message } })); + return true; + } +} + +/** + * 禁用/启用特定提供商配置 + */ +export async function handleDisableEnableProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid, action) { + try { + const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; + let providerPools = {}; + + // Load existing pools + if (existsSync(filePath)) { + try { + const fileContent = readFileSync(filePath, 'utf-8'); + providerPools = JSON.parse(fileContent); + } catch (readError) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'Provider pools file not found' } })); + return true; + } + } + + // Find and update the provider + const providers = providerPools[providerType] || []; + const providerIndex = providers.findIndex(p => p.uuid === providerUuid); + + if (providerIndex === -1) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'Provider not found' } })); + return true; + } + + // Update isDisabled field + const provider = providers[providerIndex]; + provider.isDisabled = action === 'disable'; + + // Save to file + writeFileSync(filePath, JSON.stringify(providerPools, null, 2), 'utf-8'); + console.log(`[UI API] ${action === 'disable' ? 'Disabled' : 'Enabled'} provider ${providerUuid} in ${providerType}`); + + // Update provider pool manager if available + if (providerPoolManager) { + providerPoolManager.providerPools = providerPools; + + // Call the appropriate method + if (action === 'disable') { + providerPoolManager.disableProvider(providerType, provider); + } else { + providerPoolManager.enableProvider(providerType, provider); + } + } + + // 广播更新事件 + broadcastEvent('config_update', { + action: action, + filePath: filePath, + providerType, + providerConfig: provider, + timestamp: new Date().toISOString() + }); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: true, + message: `Provider ${action}d successfully`, + provider: provider + })); + return true; + } catch (error) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: error.message } })); + return true; + } +} + +/** + * 重置特定提供商类型的所有提供商健康状态 + */ +export async function handleResetProviderHealth(req, res, currentConfig, providerPoolManager, providerType) { + try { + const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; + let providerPools = {}; + + // Load existing pools + if (existsSync(filePath)) { + try { + const fileContent = readFileSync(filePath, 'utf-8'); + providerPools = JSON.parse(fileContent); + } catch (readError) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'Provider pools file not found' } })); + return true; + } + } + + // Reset health status for all providers of this type + const providers = providerPools[providerType] || []; + + if (providers.length === 0) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'No providers found for this type' } })); + return true; + } + + let resetCount = 0; + providers.forEach(provider => { + if (!provider.isHealthy) { + provider.isHealthy = true; + provider.errorCount = 0; + provider.lastErrorTime = null; + resetCount++; + } + }); + + // Save to file + writeFileSync(filePath, JSON.stringify(providerPools, null, 2), 'utf-8'); + console.log(`[UI API] Reset health status for ${resetCount} providers in ${providerType}`); + + // Update provider pool manager if available + if (providerPoolManager) { + providerPoolManager.providerPools = providerPools; + providerPoolManager.initializeProviderStatus(); + } + + // 广播更新事件 + broadcastEvent('config_update', { + action: 'reset_health', + filePath: filePath, + providerType, + resetCount, + timestamp: new Date().toISOString() + }); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: true, + message: `Successfully reset health status for ${resetCount} providers`, + resetCount, + totalCount: providers.length + })); + return true; + } catch (error) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: error.message } })); + return true; + } +} + +/** + * 对特定提供商类型的所有提供商执行健康检查 + */ +export async function handleHealthCheck(req, res, currentConfig, providerPoolManager, providerType) { + try { + if (!providerPoolManager) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'Provider pool manager not initialized' } })); + return true; + } + + const providers = providerPoolManager.providerStatus[providerType] || []; + + if (providers.length === 0) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'No providers found for this type' } })); + return true; + } + + console.log(`[UI API] Starting health check for ${providers.length} providers in ${providerType}`); + + // 执行健康检测(强制检查,忽略 checkHealth 配置) + const results = []; + for (const providerStatus of providers) { + const providerConfig = providerStatus.config; + + // 跳过已禁用的节点 + if (providerConfig.isDisabled) { + console.log(`[UI API] Skipping health check for disabled provider: ${providerConfig.uuid}`); + continue; + } + + try { + // 传递 forceCheck = true 强制执行健康检查,忽略 checkHealth 配置 + const healthResult = await providerPoolManager._checkProviderHealth(providerType, providerConfig, true); + + if (healthResult === null) { + results.push({ + uuid: providerConfig.uuid, + success: null, + message: 'Health check not supported for this provider type' + }); + continue; + } + + if (healthResult.success) { + providerPoolManager.markProviderHealthy(providerType, providerConfig, false, healthResult.modelName); + results.push({ + uuid: providerConfig.uuid, + success: true, + modelName: healthResult.modelName, + message: 'Healthy' + }); + } else { + providerPoolManager.markProviderUnhealthy(providerType, providerConfig, healthResult.errorMessage); + providerStatus.config.lastHealthCheckTime = new Date().toISOString(); + if (healthResult.modelName) { + providerStatus.config.lastHealthCheckModel = healthResult.modelName; + } + results.push({ + uuid: providerConfig.uuid, + success: false, + modelName: healthResult.modelName, + message: healthResult.errorMessage || 'Check failed' + }); + } + } catch (error) { + providerPoolManager.markProviderUnhealthy(providerType, providerConfig, error.message); + results.push({ + uuid: providerConfig.uuid, + success: false, + message: error.message + }); + } + } + + // 保存更新后的状态到文件 + const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; + + // 从 providerStatus 构建 providerPools 对象并保存 + const providerPools = {}; + for (const pType in providerPoolManager.providerStatus) { + providerPools[pType] = providerPoolManager.providerStatus[pType].map(ps => ps.config); + } + writeFileSync(filePath, JSON.stringify(providerPools, null, 2), 'utf-8'); + + const successCount = results.filter(r => r.success === true).length; + const failCount = results.filter(r => r.success === false).length; + + console.log(`[UI API] Health check completed for ${providerType}: ${successCount} healthy, ${failCount} unhealthy`); + + // 广播更新事件 + broadcastEvent('config_update', { + action: 'health_check', + filePath: filePath, + providerType, + results, + timestamp: new Date().toISOString() + }); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: true, + message: `Health check completed: ${successCount} healthy, ${failCount} unhealthy`, + successCount, + failCount, + totalCount: providers.length, + results + })); + return true; + } catch (error) { + console.error('[UI API] Health check error:', error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: error.message } })); + return true; + } +} + +/** + * 快速链接配置文件到对应的提供商 + */ +export async function handleQuickLinkProvider(req, res, currentConfig, providerPoolManager) { + try { + const body = await getRequestBody(req); + const { filePath } = body; + + if (!filePath) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'filePath is required' } })); + return true; + } + + const normalizedPath = filePath.replace(/\\/g, '/').toLowerCase(); + + // 根据文件路径自动识别提供商类型 + const providerMapping = detectProviderFromPath(normalizedPath); + + if (!providerMapping) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: 'Unable to identify provider type for config file, please ensure file is in configs/kiro/, configs/gemini/, configs/qwen/ or configs/antigravity/ directory' + } + })); + return true; + } + + const { providerType, credPathKey, defaultCheckModel, displayName } = providerMapping; + const poolsFilePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; + + // Load existing pools + let providerPools = {}; + if (existsSync(poolsFilePath)) { + try { + const fileContent = readFileSync(poolsFilePath, 'utf-8'); + providerPools = JSON.parse(fileContent); + } catch (readError) { + console.warn('[UI API] Failed to read existing provider pools:', readError.message); + } + } + + // Ensure provider type array exists + if (!providerPools[providerType]) { + providerPools[providerType] = []; + } + + // Check if already linked - 使用标准化路径进行比较 + const normalizedForComparison = filePath.replace(/\\/g, '/'); + const isAlreadyLinked = providerPools[providerType].some(p => { + const existingPath = p[credPathKey]; + if (!existingPath) return false; + const normalizedExistingPath = existingPath.replace(/\\/g, '/'); + return normalizedExistingPath === normalizedForComparison || + normalizedExistingPath === './' + normalizedForComparison || + './' + normalizedExistingPath === normalizedForComparison; + }); + + if (isAlreadyLinked) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'This config file is already linked' } })); + return true; + } + + // Create new provider config based on provider type + const newProvider = createProviderConfig({ + credPathKey, + credPath: formatSystemPath(filePath), + defaultCheckModel, + needsProjectId: providerMapping.needsProjectId + }); + + providerPools[providerType].push(newProvider); + + // Save to file + writeFileSync(poolsFilePath, JSON.stringify(providerPools, null, 2), 'utf-8'); + console.log(`[UI API] Quick linked config: ${filePath} -> ${providerType}`); + + // Update provider pool manager if available + if (providerPoolManager) { + providerPoolManager.providerPools = providerPools; + providerPoolManager.initializeProviderStatus(); + } + + // Broadcast update event + broadcastEvent('config_update', { + action: 'quick_link', + filePath: poolsFilePath, + providerType, + newProvider, + timestamp: new Date().toISOString() + }); + + broadcastEvent('provider_update', { + action: 'add', + providerType, + providerConfig: newProvider, + timestamp: new Date().toISOString() + }); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: true, + message: `Config successfully linked to ${displayName}`, + provider: newProvider, + providerType: providerType + })); + return true; + } catch (error) { + console.error('[UI API] Quick link failed:', error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: 'Link failed: ' + error.message + } + })); + return true; + } +} \ No newline at end of file diff --git a/src/ui-modules/system-api.js b/src/ui-modules/system-api.js new file mode 100644 index 0000000..8905030 --- /dev/null +++ b/src/ui-modules/system-api.js @@ -0,0 +1,120 @@ +import { existsSync, readFileSync } from 'fs'; +import path from 'path'; +import { getCpuUsagePercent } from './system-monitor.js'; + +/** + * 获取系统信息 + */ +export async function handleGetSystem(req, res) { + const memUsage = process.memoryUsage(); + + // 读取版本号 + let appVersion = 'unknown'; + try { + const versionFilePath = path.join(process.cwd(), 'VERSION'); + if (existsSync(versionFilePath)) { + appVersion = readFileSync(versionFilePath, 'utf8').trim(); + } + } catch (error) { + console.warn('[UI API] Failed to read VERSION file:', error.message); + } + + // 计算 CPU 使用率 + const cpuUsage = getCpuUsagePercent(); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + appVersion: appVersion, + nodeVersion: process.version, + serverTime: new Date().toLocaleString(), + memoryUsage: `${Math.round(memUsage.heapUsed / 1024 / 1024)} MB / ${Math.round(memUsage.heapTotal / 1024 / 1024)} MB`, + cpuUsage: cpuUsage, + uptime: process.uptime() + })); + return true; +} + +/** + * 健康检查接口(用于前端token验证) + */ +export async function handleHealthCheck(req, res) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ status: 'ok', timestamp: Date.now() })); + return true; +} + +/** + * 获取服务模式信息 + */ +export async function handleGetServiceMode(req, res) { + const IS_WORKER_PROCESS = process.env.IS_WORKER_PROCESS === 'true'; + const masterPort = process.env.MASTER_PORT || 3100; + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + mode: IS_WORKER_PROCESS ? 'worker' : 'standalone', + pid: process.pid, + ppid: process.ppid, + uptime: process.uptime(), + canAutoRestart: IS_WORKER_PROCESS && !!process.send, + masterPort: IS_WORKER_PROCESS ? masterPort : null, + nodeVersion: process.version, + platform: process.platform + })); + return true; +} + +/** + * 重启服务端点 - 支持主进程-子进程架构 + */ +export async function handleRestartService(req, res) { + try { + const IS_WORKER_PROCESS = process.env.IS_WORKER_PROCESS === 'true'; + + if (IS_WORKER_PROCESS && process.send) { + // 作为子进程运行,通知主进程重启 + console.log('[UI API] Requesting restart from master process...'); + process.send({ type: 'restart_request' }); + + // 广播重启事件 + const { broadcastEvent } = await import('./event-broadcast.js'); + broadcastEvent('service_restart', { + action: 'restart_requested', + timestamp: new Date().toISOString(), + message: 'Service restart requested, worker will be restarted by master process' + }); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: true, + message: 'Restart request sent to master process', + mode: 'worker', + details: { + workerPid: process.pid, + restartMethod: 'master_controlled' + } + })); + } else { + // 独立运行模式,无法自动重启 + console.log('[UI API] Service is running in standalone mode, cannot auto-restart'); + + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: false, + message: 'Service is running in standalone mode. Please use master.js to enable auto-restart feature.', + mode: 'standalone', + hint: 'Start the service with: node src/master.js [args]' + })); + } + return true; + } catch (error) { + console.error('[UI API] Failed to restart service:', error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: 'Failed to restart service: ' + error.message + } + })); + return true; + } +} \ No newline at end of file diff --git a/src/ui-modules/system-monitor.js b/src/ui-modules/system-monitor.js new file mode 100644 index 0000000..90b997a --- /dev/null +++ b/src/ui-modules/system-monitor.js @@ -0,0 +1,42 @@ +import os from 'os'; + +// CPU 使用率计算相关变量 +let previousCpuInfo = null; + +/** + * 获取 CPU 使用率百分比 + * @returns {string} CPU 使用率字符串,如 "25.5%" + */ +export function getCpuUsagePercent() { + const cpus = os.cpus(); + + let totalIdle = 0; + let totalTick = 0; + + for (const cpu of cpus) { + for (const type in cpu.times) { + totalTick += cpu.times[type]; + } + totalIdle += cpu.times.idle; + } + + const currentCpuInfo = { + idle: totalIdle, + total: totalTick + }; + + let cpuPercent = 0; + + if (previousCpuInfo) { + const idleDiff = currentCpuInfo.idle - previousCpuInfo.idle; + const totalDiff = currentCpuInfo.total - previousCpuInfo.total; + + if (totalDiff > 0) { + cpuPercent = 100 - (100 * idleDiff / totalDiff); + } + } + + previousCpuInfo = currentCpuInfo; + + return `${cpuPercent.toFixed(1)}%`; +} \ No newline at end of file diff --git a/src/ui-modules/update-api.js b/src/ui-modules/update-api.js new file mode 100644 index 0000000..43db3d8 --- /dev/null +++ b/src/ui-modules/update-api.js @@ -0,0 +1,495 @@ +import { existsSync, readFileSync, writeFileSync } from 'fs'; +import { promises as fs } from 'fs'; +import path from 'path'; +import { exec } from 'child_process'; +import { promisify } from 'util'; + +const execAsync = promisify(exec); + +/** + * 比较版本号 + * @param {string} v1 - 版本号1 + * @param {string} v2 - 版本号2 + * @returns {number} 1 if v1 > v2, -1 if v1 < v2, 0 if equal + */ +function compareVersions(v1, v2) { + // 移除 'v' 前缀(如果有) + const clean1 = v1.replace(/^v/, ''); + const clean2 = v2.replace(/^v/, ''); + + const parts1 = clean1.split('.').map(Number); + const parts2 = clean2.split('.').map(Number); + + const maxLen = Math.max(parts1.length, parts2.length); + + for (let i = 0; i < maxLen; i++) { + const num1 = parts1[i] || 0; + const num2 = parts2[i] || 0; + + if (num1 > num2) return 1; + if (num1 < num2) return -1; + } + + return 0; +} + +/** + * 通过 GitHub API 获取最新版本 + * @returns {Promise} 最新版本号或 null + */ +async function getLatestVersionFromGitHub() { + const GITHUB_REPO = 'justlovemaki/AIClient-2-API'; + const apiUrl = `https://api.github.com/repos/${GITHUB_REPO}/tags`; + + try { + console.log('[Update] Fetching latest version from GitHub API...'); + const response = await fetch(apiUrl, { + headers: { + 'Accept': 'application/vnd.github.v3+json', + 'User-Agent': 'AIClient2API-UpdateChecker' + }, + timeout: 10000 + }); + + if (!response.ok) { + throw new Error(`GitHub API returned ${response.status}: ${response.statusText}`); + } + + const tags = await response.json(); + + if (!Array.isArray(tags) || tags.length === 0) { + return null; + } + + // 提取版本号并排序 + const versions = tags + .map(tag => tag.name) + .filter(name => /^v?\d+\.\d+/.test(name)); // 只保留符合版本号格式的 tag + + if (versions.length === 0) { + return null; + } + + // 按版本号排序(降序) + versions.sort((a, b) => compareVersions(b, a)); + + return versions[0]; + } catch (error) { + console.warn('[Update] Failed to fetch from GitHub API:', error.message); + return null; + } +} + +/** + * 检查是否有新版本可用 + * 支持两种模式: + * 1. Git 仓库模式:通过 git 命令获取最新 tag + * 2. Docker/非 Git 模式:通过 GitHub API 获取最新版本 + * @returns {Promise} 更新信息 + */ +export async function checkForUpdates() { + const versionFilePath = path.join(process.cwd(), 'VERSION'); + + // 读取本地版本 + let localVersion = 'unknown'; + try { + if (existsSync(versionFilePath)) { + localVersion = readFileSync(versionFilePath, 'utf-8').trim(); + } + } catch (error) { + console.warn('[Update] Failed to read local VERSION file:', error.message); + } + + // 检查是否在 git 仓库中 + let isGitRepo = false; + try { + await execAsync('git rev-parse --git-dir'); + isGitRepo = true; + } catch (error) { + isGitRepo = false; + console.log('[Update] Not in a Git repository, will use GitHub API to check for updates'); + } + + let latestTag = null; + let updateMethod = 'unknown'; + + if (isGitRepo) { + // Git 仓库模式:使用 git 命令 + updateMethod = 'git'; + + // 获取远程 tags + try { + console.log('[Update] Fetching remote tags...'); + await execAsync('git fetch --tags'); + } catch (error) { + console.warn('[Update] Failed to fetch tags via git, falling back to GitHub API:', error.message); + // 如果 git fetch 失败,回退到 GitHub API + latestTag = await getLatestVersionFromGitHub(); + updateMethod = 'github_api'; + } + + // 如果 git fetch 成功,获取最新的 tag + if (!latestTag && updateMethod === 'git') { + const isWindows = process.platform === 'win32'; + + try { + if (isWindows) { + // Windows: 使用 git for-each-ref,这是跨平台兼容的方式 + const { stdout } = await execAsync('git for-each-ref --sort=-v:refname --format="%(refname:short)" refs/tags --count=1'); + latestTag = stdout.trim(); + } else { + // Linux/macOS: 使用 head 命令,更高效 + const { stdout } = await execAsync('git tag --sort=-v:refname | head -n 1'); + latestTag = stdout.trim(); + } + } catch (error) { + // 备用方案:获取所有 tags 并在 JavaScript 中排序 + try { + const { stdout } = await execAsync('git tag'); + const tags = stdout.trim().split('\n').filter(t => t); + if (tags.length > 0) { + // 按版本号排序(降序) + tags.sort((a, b) => compareVersions(b, a)); + latestTag = tags[0]; + } + } catch (e) { + console.warn('[Update] Failed to get latest tag via git, falling back to GitHub API:', e.message); + latestTag = await getLatestVersionFromGitHub(); + updateMethod = 'github_api'; + } + } + } + } else { + // 非 Git 仓库模式(如 Docker 容器):使用 GitHub API + updateMethod = 'github_api'; + latestTag = await getLatestVersionFromGitHub(); + } + + if (!latestTag) { + return { + hasUpdate: false, + localVersion, + latestVersion: null, + updateMethod, + error: 'Unable to get latest version information' + }; + } + + // 比较版本 + const comparison = compareVersions(latestTag, localVersion); + const hasUpdate = comparison > 0; + + console.log(`[Update] Local version: ${localVersion}, Latest version: ${latestTag}, Has update: ${hasUpdate}, Method: ${updateMethod}`); + + return { + hasUpdate, + localVersion, + latestVersion: latestTag, + updateMethod, + error: null + }; +} + +/** + * 执行更新操作 + * @returns {Promise} 更新结果 + */ +export async function performUpdate() { + // 首先检查是否有更新 + const updateInfo = await checkForUpdates(); + + if (updateInfo.error) { + throw new Error(updateInfo.error); + } + + if (!updateInfo.hasUpdate) { + return { + success: true, + message: 'Already at the latest version', + localVersion: updateInfo.localVersion, + latestVersion: updateInfo.latestVersion, + updated: false + }; + } + + const latestTag = updateInfo.latestVersion; + + // 检查更新方式 - 如果是通过 GitHub API 获取的版本信息,说明不在 Git 仓库中 + if (updateInfo.updateMethod === 'github_api') { + // Docker/非 Git 环境,通过下载 tarball 更新 + console.log('[Update] Running in Docker/non-Git environment, will download and extract tarball'); + return await performTarballUpdate(updateInfo.localVersion, latestTag); + } + + console.log(`[Update] Starting update to ${latestTag}...`); + + // 检查是否有未提交的更改 + try { + const { stdout: statusOutput } = await execAsync('git status --porcelain'); + if (statusOutput.trim()) { + // 有未提交的更改,先 stash + console.log('[Update] Stashing local changes...'); + await execAsync('git stash'); + } + } catch (error) { + console.warn('[Update] Failed to check git status:', error.message); + } + + // 执行 checkout 到最新 tag + try { + console.log(`[Update] Checking out to ${latestTag}...`); + await execAsync(`git checkout ${latestTag}`); + } catch (error) { + console.error('[Update] Failed to checkout:', error.message); + throw new Error('Failed to switch to new version: ' + error.message); + } + + // 更新 VERSION 文件(如果 tag 和 VERSION 文件不同步) + const versionFilePath = path.join(process.cwd(), 'VERSION'); + try { + const newVersion = latestTag.replace(/^v/, ''); + writeFileSync(versionFilePath, newVersion, 'utf-8'); + console.log(`[Update] VERSION file updated to ${newVersion}`); + } catch (error) { + console.warn('[Update] Failed to update VERSION file:', error.message); + } + + // 检查是否需要安装依赖 + let needsRestart = false; + try { + // 确保本地版本号有 v 前缀,以匹配 git tag 格式 + const localVersionTag = updateInfo.localVersion.startsWith('v') ? updateInfo.localVersion : `v${updateInfo.localVersion}`; + const { stdout: diffOutput } = await execAsync(`git diff ${localVersionTag}..${latestTag} --name-only`); + if (diffOutput.includes('package.json') || diffOutput.includes('package-lock.json')) { + console.log('[Update] package.json changed, running npm install...'); + await execAsync('npm install'); + needsRestart = true; + } + } catch (error) { + console.warn('[Update] Failed to check package changes:', error.message); + } + + console.log(`[Update] Update completed successfully to ${latestTag}`); + + return { + success: true, + message: `Successfully updated to version ${latestTag}`, + localVersion: updateInfo.localVersion, + latestVersion: latestTag, + updated: true, + updateMethod: 'git', + needsRestart: needsRestart, + restartMessage: needsRestart ? 'Dependencies updated, recommend restarting service to apply changes' : null + }; +} + +/** + * 通过下载 tarball 执行更新(用于 Docker/非 Git 环境) + * @param {string} localVersion - 本地版本 + * @param {string} latestTag - 最新版本 tag + * @returns {Promise} 更新结果 + */ +async function performTarballUpdate(localVersion, latestTag) { + const GITHUB_REPO = 'justlovemaki/AIClient-2-API'; + const tarballUrl = `https://github.com/${GITHUB_REPO}/archive/refs/tags/${latestTag}.tar.gz`; + const appDir = process.cwd(); + const tempDir = path.join(appDir, '.update_temp'); + const tarballPath = path.join(tempDir, 'update.tar.gz'); + + console.log(`[Update] Starting tarball update to ${latestTag}...`); + console.log(`[Update] Download URL: ${tarballUrl}`); + + try { + // 1. 创建临时目录 + await fs.mkdir(tempDir, { recursive: true }); + console.log('[Update] Created temp directory'); + + // 2. 下载 tarball + console.log('[Update] Downloading tarball...'); + const response = await fetch(tarballUrl, { + headers: { + 'User-Agent': 'AIClient2API-Updater' + }, + redirect: 'follow' + }); + + if (!response.ok) { + throw new Error(`Failed to download tarball: ${response.status} ${response.statusText}`); + } + + const arrayBuffer = await response.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + await fs.writeFile(tarballPath, buffer); + console.log(`[Update] Downloaded tarball (${buffer.length} bytes)`); + + // 3. 解压 tarball + console.log('[Update] Extracting tarball...'); + await execAsync(`tar -xzf "${tarballPath}" -C "${tempDir}"`); + + // 4. 找到解压后的目录(格式通常是 repo-name-tag) + const extractedItems = await fs.readdir(tempDir); + const extractedDir = extractedItems.find(item => + item.startsWith('AIClient-2-API-') || item.startsWith('AIClient2API-') + ); + + if (!extractedDir) { + throw new Error('Could not find extracted directory'); + } + + const sourcePath = path.join(tempDir, extractedDir); + console.log(`[Update] Extracted to: ${sourcePath}`); + + // 5. 备份当前的 package.json 用于比较 + const oldPackageJson = existsSync(path.join(appDir, 'package.json')) + ? readFileSync(path.join(appDir, 'package.json'), 'utf-8') + : null; + + // 6. 定义需要保留的目录和文件(不被覆盖) + const preservePaths = [ + 'configs', // 用户配置目录 + 'node_modules', // 依赖目录 + '.update_temp', // 临时更新目录 + 'logs' // 日志目录 + ]; + + // 7. 复制新文件到应用目录 + console.log('[Update] Copying new files...'); + const sourceItems = await fs.readdir(sourcePath); + + for (const item of sourceItems) { + // 跳过需要保留的目录 + if (preservePaths.includes(item)) { + console.log(`[Update] Skipping preserved path: ${item}`); + continue; + } + + const srcItemPath = path.join(sourcePath, item); + const destItemPath = path.join(appDir, item); + + // 删除旧文件/目录(如果存在) + if (existsSync(destItemPath)) { + const stat = await fs.stat(destItemPath); + if (stat.isDirectory()) { + await fs.rm(destItemPath, { recursive: true, force: true }); + } else { + await fs.unlink(destItemPath); + } + } + + // 复制新文件/目录 + await copyRecursive(srcItemPath, destItemPath); + console.log(`[Update] Copied: ${item}`); + } + + // 8. 检查是否需要更新依赖 + let needsRestart = true; // tarball 更新后总是建议重启 + let needsNpmInstall = false; + + if (oldPackageJson) { + const newPackageJson = readFileSync(path.join(appDir, 'package.json'), 'utf-8'); + if (oldPackageJson !== newPackageJson) { + console.log('[Update] package.json changed, running npm install...'); + needsNpmInstall = true; + try { + await execAsync('npm install', { cwd: appDir }); + console.log('[Update] npm install completed'); + } catch (npmError) { + console.error('[Update] npm install failed:', npmError.message); + // 不抛出错误,继续更新流程 + } + } + } + + // 9. 清理临时目录 + console.log('[Update] Cleaning up...'); + await fs.rm(tempDir, { recursive: true, force: true }); + + console.log(`[Update] Tarball update completed successfully to ${latestTag}`); + + return { + success: true, + message: `Successfully updated to version ${latestTag}`, + localVersion: localVersion, + latestVersion: latestTag, + updated: true, + updateMethod: 'tarball', + needsRestart: needsRestart, + needsNpmInstall: needsNpmInstall, + restartMessage: 'Code updated, please restart the service to apply changes' + }; + + } catch (error) { + // 清理临时目录 + try { + if (existsSync(tempDir)) { + await fs.rm(tempDir, { recursive: true, force: true }); + } + } catch (cleanupError) { + console.warn('[Update] Failed to cleanup temp directory:', cleanupError.message); + } + + console.error('[Update] Tarball update failed:', error.message); + throw new Error(`Tarball update failed: ${error.message}`); + } +} + +/** + * 递归复制文件或目录 + * @param {string} src - 源路径 + * @param {string} dest - 目标路径 + */ +async function copyRecursive(src, dest) { + const stat = await fs.stat(src); + + if (stat.isDirectory()) { + await fs.mkdir(dest, { recursive: true }); + const items = await fs.readdir(src); + for (const item of items) { + await copyRecursive(path.join(src, item), path.join(dest, item)); + } + } else { + await fs.copyFile(src, dest); + } +} + +/** + * 检查更新 + */ +export async function handleCheckUpdate(req, res) { + try { + const updateInfo = await checkForUpdates(); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(updateInfo)); + return true; + } catch (error) { + console.error('[UI API] Failed to check for updates:', error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: 'Failed to check for updates: ' + error.message + } + })); + return true; + } +} + +/** + * 执行更新 + */ +export async function handlePerformUpdate(req, res) { + try { + const updateResult = await performUpdate(); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(updateResult)); + return true; + } catch (error) { + console.error('[UI API] Failed to perform update:', error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: 'Update failed: ' + error.message + } + })); + return true; + } +} \ No newline at end of file diff --git a/src/ui-modules/upload-config-api.js b/src/ui-modules/upload-config-api.js new file mode 100644 index 0000000..aa673a4 --- /dev/null +++ b/src/ui-modules/upload-config-api.js @@ -0,0 +1,200 @@ +import { existsSync } from 'fs'; +import { promises as fs } from 'fs'; +import path from 'path'; +import AdmZip from 'adm-zip'; +import { broadcastEvent } from './event-broadcast.js'; +import { scanConfigFiles } from './config-scanner.js'; + +/** + * 获取上传配置文件列表 + */ +export async function handleGetUploadConfigs(req, res, currentConfig, providerPoolManager) { + try { + const configFiles = await scanConfigFiles(currentConfig, providerPoolManager); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(configFiles)); + return true; + } catch (error) { + console.error('[UI API] Failed to scan config files:', error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: 'Failed to scan config files: ' + error.message + } + })); + return true; + } +} + +/** + * 查看特定配置文件 + */ +export async function handleViewConfigFile(req, res, filePath) { + try { + const fullPath = path.join(process.cwd(), filePath); + + // 安全检查:确保文件路径在允许的目录内 + const allowedDirs = ['configs']; + const relativePath = path.relative(process.cwd(), fullPath); + const isAllowed = allowedDirs.some(dir => relativePath.startsWith(dir + path.sep) || relativePath === dir); + + if (!isAllowed) { + res.writeHead(403, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: 'Access denied: can only view files in configs directory' + } + })); + return true; + } + + if (!existsSync(fullPath)) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: 'File does not exist' + } + })); + return true; + } + + const content = await fs.readFile(fullPath, 'utf-8'); + const stats = await fs.stat(fullPath); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + path: relativePath, + content: content, + size: stats.size, + modified: stats.mtime.toISOString(), + name: path.basename(fullPath) + })); + return true; + } catch (error) { + console.error('[UI API] Failed to view config file:', error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: 'Failed to view config file: ' + error.message + } + })); + return true; + } +} + +/** + * 删除特定配置文件 + */ +export async function handleDeleteConfigFile(req, res, filePath) { + try { + const fullPath = path.join(process.cwd(), filePath); + + // 安全检查:确保文件路径在允许的目录内 + const allowedDirs = ['configs']; + const relativePath = path.relative(process.cwd(), fullPath); + const isAllowed = allowedDirs.some(dir => relativePath.startsWith(dir + path.sep) || relativePath === dir); + + if (!isAllowed) { + res.writeHead(403, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: 'Access denied: can only delete files in configs directory' + } + })); + return true; + } + + if (!existsSync(fullPath)) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: 'File does not exist' + } + })); + return true; + } + + + await fs.unlink(fullPath); + + // 广播更新事件 + broadcastEvent('config_update', { + action: 'delete', + filePath: relativePath, + timestamp: new Date().toISOString() + }); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: true, + message: 'File deleted successfully', + filePath: relativePath + })); + return true; + } catch (error) { + console.error('[UI API] Failed to delete config file:', error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: 'Failed to delete config file: ' + error.message + } + })); + return true; + } +} + +/** + * 下载所有配置为 zip + */ +export async function handleDownloadAllConfigs(req, res) { + try { + const configsPath = path.join(process.cwd(), 'configs'); + if (!existsSync(configsPath)) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'configs directory does not exist' } })); + return true; + } + + const zip = new AdmZip(); + + // 递归添加目录函数 + const addDirectoryToZip = async (dirPath, zipPath = '') => { + const items = await fs.readdir(dirPath, { withFileTypes: true }); + for (const item of items) { + const fullPath = path.join(dirPath, item.name); + const itemZipPath = zipPath ? path.join(zipPath, item.name) : item.name; + + if (item.isFile()) { + const content = await fs.readFile(fullPath); + zip.addFile(itemZipPath.replace(/\\/g, '/'), content); + } else if (item.isDirectory()) { + await addDirectoryToZip(fullPath, itemZipPath); + } + } + }; + + await addDirectoryToZip(configsPath); + + const zipBuffer = zip.toBuffer(); + const filename = `configs_backup_${new Date().toISOString().replace(/[:.]/g, '-')}.zip`; + + res.writeHead(200, { + 'Content-Type': 'application/zip', + 'Content-Disposition': `attachment; filename="${filename}"`, + 'Content-Length': zipBuffer.length + }); + res.end(zipBuffer); + + console.log(`[UI API] All configs downloaded as zip: ${filename}`); + return true; + } catch (error) { + console.error('[UI API] Failed to download all configs:', error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: 'Failed to download zip: ' + error.message + } + })); + return true; + } +} \ No newline at end of file diff --git a/src/ui-modules/usage-api.js b/src/ui-modules/usage-api.js new file mode 100644 index 0000000..164434c --- /dev/null +++ b/src/ui-modules/usage-api.js @@ -0,0 +1,290 @@ +import { CONFIG } from '../config-manager.js'; +import { serviceInstances, getServiceAdapter } from '../adapter.js'; +import { formatKiroUsage, formatGeminiUsage, formatAntigravityUsage } from '../usage-service.js'; +import { readUsageCache, writeUsageCache, readProviderUsageCache, updateProviderUsageCache } from './usage-cache.js'; +import path from 'path'; + +/** + * 获取所有支持用量查询的提供商的用量信息 + * @param {Object} currentConfig - 当前配置 + * @param {Object} providerPoolManager - 提供商池管理器 + * @returns {Promise} 所有提供商的用量信息 + */ +async function getAllProvidersUsage(currentConfig, providerPoolManager) { + const results = { + timestamp: new Date().toISOString(), + providers: {} + }; + + // 支持用量查询的提供商列表 + const supportedProviders = ['claude-kiro-oauth', 'gemini-cli-oauth', 'gemini-antigravity']; + + // 并发获取所有提供商的用量数据 + const usagePromises = supportedProviders.map(async (providerType) => { + try { + const providerUsage = await getProviderTypeUsage(providerType, currentConfig, providerPoolManager); + return { providerType, data: providerUsage, success: true }; + } catch (error) { + return { + providerType, + data: { + error: error.message, + instances: [] + }, + success: false + }; + } + }); + + // 等待所有并发请求完成 + const usageResults = await Promise.all(usagePromises); + + // 将结果整合到 results.providers 中 + for (const result of usageResults) { + results.providers[result.providerType] = result.data; + } + + return results; +} + +/** + * 获取指定提供商类型的用量信息 + * @param {string} providerType - 提供商类型 + * @param {Object} currentConfig - 当前配置 + * @param {Object} providerPoolManager - 提供商池管理器 + * @returns {Promise} 提供商用量信息 + */ +async function getProviderTypeUsage(providerType, currentConfig, providerPoolManager) { + const result = { + providerType, + instances: [], + totalCount: 0, + successCount: 0, + errorCount: 0 + }; + + // 获取提供商池中的所有实例 + let providers = []; + if (providerPoolManager && providerPoolManager.providerPools && providerPoolManager.providerPools[providerType]) { + providers = providerPoolManager.providerPools[providerType]; + } else if (currentConfig.providerPools && currentConfig.providerPools[providerType]) { + providers = currentConfig.providerPools[providerType]; + } + + result.totalCount = providers.length; + + // 遍历所有提供商实例获取用量 + for (const provider of providers) { + const providerKey = providerType + (provider.uuid || ''); + let adapter = serviceInstances[providerKey]; + + const instanceResult = { + uuid: provider.uuid || 'unknown', + name: getProviderDisplayName(provider, providerType), + isHealthy: provider.isHealthy !== false, + isDisabled: provider.isDisabled === true, + success: false, + usage: null, + error: null + }; + + // First check if disabled, skip initialization for disabled providers + if (provider.isDisabled) { + instanceResult.error = 'Provider is disabled'; + result.errorCount++; + } else if (!adapter) { + // Service instance not initialized, try auto-initialization + try { + console.log(`[Usage API] Auto-initializing service adapter for ${providerType}: ${provider.uuid}`); + // Build configuration object + const serviceConfig = { + ...CONFIG, + ...provider, + MODEL_PROVIDER: providerType + }; + adapter = getServiceAdapter(serviceConfig); + } catch (initError) { + console.error(`[Usage API] Failed to initialize adapter for ${providerType}: ${provider.uuid}:`, initError.message); + instanceResult.error = `Service instance initialization failed: ${initError.message}`; + result.errorCount++; + } + } + + // If adapter exists (including just initialized), and no error, try to get usage + if (adapter && !instanceResult.error) { + try { + const usage = await getAdapterUsage(adapter, providerType); + instanceResult.success = true; + instanceResult.usage = usage; + result.successCount++; + } catch (error) { + instanceResult.error = error.message; + result.errorCount++; + } + } + + result.instances.push(instanceResult); + } + + return result; +} + +/** + * 从适配器获取用量信息 + * @param {Object} adapter - 服务适配器 + * @param {string} providerType - 提供商类型 + * @returns {Promise} 用量信息 + */ +async function getAdapterUsage(adapter, providerType) { + if (providerType === 'claude-kiro-oauth') { + if (typeof adapter.getUsageLimits === 'function') { + const rawUsage = await adapter.getUsageLimits(); + return formatKiroUsage(rawUsage); + } else if (adapter.kiroApiService && typeof adapter.kiroApiService.getUsageLimits === 'function') { + const rawUsage = await adapter.kiroApiService.getUsageLimits(); + return formatKiroUsage(rawUsage); + } + throw new Error('This adapter does not support usage query'); + } + + if (providerType === 'gemini-cli-oauth') { + if (typeof adapter.getUsageLimits === 'function') { + const rawUsage = await adapter.getUsageLimits(); + return formatGeminiUsage(rawUsage); + } else if (adapter.geminiApiService && typeof adapter.geminiApiService.getUsageLimits === 'function') { + const rawUsage = await adapter.geminiApiService.getUsageLimits(); + return formatGeminiUsage(rawUsage); + } + throw new Error('This adapter does not support usage query'); + } + + if (providerType === 'gemini-antigravity') { + if (typeof adapter.getUsageLimits === 'function') { + const rawUsage = await adapter.getUsageLimits(); + return formatAntigravityUsage(rawUsage); + } else if (adapter.antigravityApiService && typeof adapter.antigravityApiService.getUsageLimits === 'function') { + const rawUsage = await adapter.antigravityApiService.getUsageLimits(); + return formatAntigravityUsage(rawUsage); + } + throw new Error('This adapter does not support usage query'); + } + + throw new Error(`Unsupported provider type: ${providerType}`); +} + +/** + * 获取提供商显示名称 + * @param {Object} provider - 提供商配置 + * @param {string} providerType - 提供商类型 + * @returns {string} 显示名称 + */ +function getProviderDisplayName(provider, providerType) { + // 优先使用自定义名称 + if (provider.customName) { + return provider.customName; + } + + // 尝试从凭据文件路径提取名称 + const credPathKey = { + 'claude-kiro-oauth': 'KIRO_OAUTH_CREDS_FILE_PATH', + 'gemini-cli-oauth': 'GEMINI_OAUTH_CREDS_FILE_PATH', + 'gemini-antigravity': 'ANTIGRAVITY_OAUTH_CREDS_FILE_PATH', + 'openai-qwen-oauth': 'QWEN_OAUTH_CREDS_FILE_PATH', + 'openai-iflow': 'IFLOW_TOKEN_FILE_PATH' + }[providerType]; + + if (credPathKey && provider[credPathKey]) { + const filePath = provider[credPathKey]; + const fileName = path.basename(filePath); + const dirName = path.basename(path.dirname(filePath)); + return `${dirName}/${fileName}`; + } + + return provider.uuid || 'Unnamed'; +} + +/** + * 获取所有提供商的用量限制 + */ +export async function handleGetUsage(req, res, currentConfig, providerPoolManager) { + try { + // 解析查询参数,检查是否需要强制刷新 + const url = new URL(req.url, `http://${req.headers.host}`); + const refresh = url.searchParams.get('refresh') === 'true'; + + let usageResults; + + if (!refresh) { + // 优先读取缓存 + const cachedData = await readUsageCache(); + if (cachedData) { + console.log('[Usage API] Returning cached usage data'); + usageResults = { ...cachedData, fromCache: true }; + } + } + + if (!usageResults) { + // 缓存不存在或需要刷新,重新查询 + console.log('[Usage API] Fetching fresh usage data'); + usageResults = await getAllProvidersUsage(currentConfig, providerPoolManager); + // 写入缓存 + await writeUsageCache(usageResults); + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(usageResults)); + return true; + } catch (error) { + console.error('[UI API] Failed to get usage:', error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: 'Failed to get usage info: ' + error.message + } + })); + return true; + } +} + +/** + * 获取特定提供商类型的用量限制 + */ +export async function handleGetProviderUsage(req, res, currentConfig, providerPoolManager, providerType) { + try { + // 解析查询参数,检查是否需要强制刷新 + const url = new URL(req.url, `http://${req.headers.host}`); + const refresh = url.searchParams.get('refresh') === 'true'; + + let usageResults; + + if (!refresh) { + // Prefer reading from cache + const cachedData = await readProviderUsageCache(providerType); + if (cachedData) { + console.log(`[Usage API] Returning cached usage data for ${providerType}`); + usageResults = cachedData; + } + } + + if (!usageResults) { + // Cache does not exist or refresh required, re-query + console.log(`[Usage API] Fetching fresh usage data for ${providerType}`); + usageResults = await getProviderTypeUsage(providerType, currentConfig, providerPoolManager); + // 更新缓存 + await updateProviderUsageCache(providerType, usageResults); + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(usageResults)); + return true; + } catch (error) { + console.error(`[UI API] Failed to get usage for ${providerType}:`, error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: `Failed to get usage info for ${providerType}: ` + error.message + } + })); + return true; + } +} \ No newline at end of file diff --git a/src/ui-modules/usage-cache.js b/src/ui-modules/usage-cache.js new file mode 100644 index 0000000..0402be0 --- /dev/null +++ b/src/ui-modules/usage-cache.js @@ -0,0 +1,71 @@ +import { existsSync } from 'fs'; +import { promises as fs } from 'fs'; +import path from 'path'; + +// 用量缓存文件路径 +const USAGE_CACHE_FILE = path.join(process.cwd(), 'configs', 'usage-cache.json'); + +/** + * 读取用量缓存文件 + * @returns {Promise} 缓存的用量数据,如果不存在或读取失败则返回 null + */ +export async function readUsageCache() { + try { + if (existsSync(USAGE_CACHE_FILE)) { + const content = await fs.readFile(USAGE_CACHE_FILE, 'utf8'); + return JSON.parse(content); + } + return null; + } catch (error) { + console.warn('[Usage Cache] Failed to read usage cache:', error.message); + return null; + } +} + +/** + * 写入用量缓存文件 + * @param {Object} usageData - 用量数据 + */ +export async function writeUsageCache(usageData) { + try { + await fs.writeFile(USAGE_CACHE_FILE, JSON.stringify(usageData, null, 2), 'utf8'); + console.log('[Usage Cache] Usage data cached to', USAGE_CACHE_FILE); + } catch (error) { + console.error('[Usage Cache] Failed to write usage cache:', error.message); + } +} + +/** + * 读取特定提供商类型的用量缓存 + * @param {string} providerType - 提供商类型 + * @returns {Promise} 缓存的用量数据 + */ +export async function readProviderUsageCache(providerType) { + const cache = await readUsageCache(); + if (cache && cache.providers && cache.providers[providerType]) { + return { + ...cache.providers[providerType], + cachedAt: cache.timestamp, + fromCache: true + }; + } + return null; +} + +/** + * 更新特定提供商类型的用量缓存 + * @param {string} providerType - 提供商类型 + * @param {Object} usageData - 用量数据 + */ +export async function updateProviderUsageCache(providerType, usageData) { + let cache = await readUsageCache(); + if (!cache) { + cache = { + timestamp: new Date().toISOString(), + providers: {} + }; + } + cache.providers[providerType] = usageData; + cache.timestamp = new Date().toISOString(); + await writeUsageCache(cache); +} \ No newline at end of file diff --git a/static/app/app.js b/static/app/app.js index 772b9e8..5f39562 100644 --- a/static/app/app.js +++ b/static/app/app.js @@ -117,6 +117,7 @@ function initApp() { initUsageManager(); // 初始化用量管理功能 initImageZoom(); // 初始化图片放大功能 initPluginManager(); // 初始化插件管理功能 + initMobileMenu(); // 初始化移动端菜单 loadInitialData(); // 显示欢迎消息 @@ -147,8 +148,75 @@ function initApp() { } -// DOM加载完成后初始化应用 -document.addEventListener('DOMContentLoaded', initApp); +/** + * 初始化移动端菜单 + */ +function initMobileMenu() { + const mobileMenuToggle = document.getElementById('mobileMenuToggle'); + const headerControls = document.getElementById('headerControls'); + + if (!mobileMenuToggle || !headerControls) { + console.log('Mobile menu elements not found'); + return; + } + + // 默认隐藏header-controls + headerControls.style.display = 'none'; + + let isMenuOpen = false; + + mobileMenuToggle.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + + console.log('Mobile menu toggle clicked, current state:', isMenuOpen); + + isMenuOpen = !isMenuOpen; + + if (isMenuOpen) { + headerControls.style.display = 'flex'; + mobileMenuToggle.innerHTML = ''; + console.log('Menu opened'); + } else { + headerControls.style.display = 'none'; + mobileMenuToggle.innerHTML = ''; + console.log('Menu closed'); + } + }); + + // 点击页面其他地方关闭菜单 + document.addEventListener('click', (e) => { + if (isMenuOpen && !mobileMenuToggle.contains(e.target) && !headerControls.contains(e.target)) { + isMenuOpen = false; + headerControls.style.display = 'none'; + mobileMenuToggle.innerHTML = ''; + console.log('Menu closed by clicking outside'); + } + }); +} + +// 等待组件加载完成后初始化应用 +// 组件加载器会在所有组件加载完成后触发 'componentsLoaded' 事件 +window.addEventListener('componentsLoaded', initApp); + +// 如果组件已经加载完成(例如页面刷新后),也需要初始化 +// 检查是否有组件已经存在 +document.addEventListener('DOMContentLoaded', () => { + // 如果 sidebar 和 content 已经有内容,说明组件已加载 + const sidebarContainer = document.getElementById('sidebar-container'); + const contentContainer = document.getElementById('content-container'); + + // 如果容器不存在或为空,说明使用的是组件加载方式,等待 componentsLoaded 事件 + // 如果容器已有内容,说明是静态 HTML,直接初始化 + if (sidebarContainer && contentContainer) { + const hasContent = sidebarContainer.children.length > 0 || contentContainer.children.length > 0; + if (hasContent) { + // 静态 HTML 方式,直接初始化 + initApp(); + } + // 否则等待 componentsLoaded 事件 + } +}); // 导出全局函数供其他模块使用 window.loadProviders = loadProviders; diff --git a/static/app/base.css b/static/app/base.css new file mode 100644 index 0000000..74f14a9 --- /dev/null +++ b/static/app/base.css @@ -0,0 +1,694 @@ +/* CSS变量 - 亮色主题(默认) */ +:root { + /* 主色调 */ + --primary-color: #059669; + --primary-hover: #047857; + --primary-light: #34d399; + + /* 辅助色 */ + --secondary-color: #10b981; + --success-color: #10b981; + --danger-color: #ef4444; + --warning-color: #f59e0b; + --info-color: #3b82f6; + + /* 背景色 */ + --bg-primary: #ffffff; + --bg-secondary: #f8fafc; + --bg-tertiary: #f1f5f9; + --bg-glass: rgba(255, 255, 255, 0.8); + --bg-glass-strong: rgba(255, 255, 255, 0.95); + + /* 文本色 */ + --text-primary: #0f172a; + --text-secondary: #475569; + --text-tertiary: #94a3b8; + + /* 边框和分割线 */ + --border-color: #e2e8f0; + --border-hover: #cbd5e1; + + /* 阴影 - 更加柔和现代 */ + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.05), 0 4px 6px -2px rgba(0, 0, 0, 0.025); + --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.05), 0 10px 10px -5px rgba(0, 0, 0, 0.02); + --shadow-glass: 0 8px 32px 0 rgba(31, 38, 135, 0.07); + + /* 圆角 */ + --radius-sm: 0.375rem; + --radius-md: 0.5rem; + --radius-lg: 0.75rem; + --radius-xl: 1rem; + --radius-full: 9999px; + + /* 动画 */ + --transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + + /* 品牌色 */ + --indigo-500: #6366f1; + --indigo-600: #4f46e5; + + /* 辅助色透明度版本 */ + --primary-10: rgba(5, 150, 105, 0.1); + --primary-20: rgba(5, 150, 105, 0.2); + --primary-30: rgba(5, 150, 105, 0.3); + --primary-40: rgba(5, 150, 105, 0.4); + + --success-10: rgba(16, 185, 129, 0.1); + + --warning-15: rgba(245, 158, 11, 0.15); + --warning-20: rgba(245, 158, 11, 0.2); + --warning-25: rgba(245, 158, 11, 0.25); + --warning-30: rgba(245, 158, 11, 0.3); + --warning-40: rgba(245, 158, 11, 0.4); + --warning-50: rgba(245, 158, 11, 0.5); + + --danger-30: rgba(220, 53, 69, 0.3); + --danger-40: rgba(220, 53, 69, 0.4); + --danger-70: rgba(220, 53, 69, 0.7); + + --neutral-shadow-sm: rgba(0, 0, 0, 0.05); + --neutral-shadow-md: rgba(0, 0, 0, 0.1); + --neutral-shadow-lg: rgba(0, 0, 0, 0.15); + --neutral-shadow-20: rgba(0, 0, 0, 0.2); + --neutral-shadow-30: rgba(0, 0, 0, 0.3); + --neutral-shadow-40: rgba(0, 0, 0, 0.4); + --neutral-shadow-50: rgba(0, 0, 0, 0.5); + --neutral-shadow-85: rgba(0, 0, 0, 0.85); + --neutral-shadow-95: rgba(0, 0, 0, 0.95); + + --indigo-30: rgba(99, 102, 241, 0.3); + --indigo-40: rgba(99, 102, 241, 0.4); + + --white-20: rgba(255, 255, 255, 0.2); + + /* 基础颜色 */ + --white: #ffffff; + --black: #000000; + + /* 遮罩背景 */ + --overlay-bg: rgba(0, 0, 0, 0.6); + + /* 代码块和日志区域 */ + --code-bg: #1e1e1e; + --code-text: #d4d4d4; + + /* 主题切换按钮 */ + --theme-toggle-bg: var(--bg-tertiary); + --theme-toggle-icon: var(--text-secondary); + + /* 警告/高亮颜色 */ + --warning-bg: #fef3c7; + --warning-bg-light: #fde68a; + --warning-bg-dark: #78350f; + --warning-border: #fbbf24; + --warning-text: #92400e; + --warning-text-dark: #d97706; + --warning-text-darker: #b45309; + --warning-bg-alt: #fffbeb; + + /* 成功/健康颜色 */ + --success-bg: #d1fae5; + --success-bg-light: #ecfdf5; + --success-bg-alt: #f0fdf4; + --success-text: #065f46; + --success-text-light: #6ee7b7; + + /* 错误/危险颜色 */ + --danger-bg: #fee2e2; + --danger-bg-light: #fef2f2; + --danger-bg-alt: #fff5f5; + --danger-bg-medium: #fed7d7; + --danger-border: #fca5a5; + --danger-border-light: #feb2b2; + --danger-border-dark: #fecaca; + --danger-text: #991b1b; + --danger-text-light: #7f1d1d; + --danger-text-dark: #742a2a; + --danger-icon: #e53e3e; + --danger-label: #c53030; + --danger-alt: #dc3545; + --danger-secondary: #fd7e14; + + /* 信息/蓝色颜色 */ + --info-bg: #dbeafe; + --info-bg-light: #e0f2fe; + --info-bg-lighter: #eff6ff; + --info-bg-alt: #f0f9ff; + --info-text: #1e40af; + --info-text-dark: #0369a1; + --info-text-darker: #075985; + --info-border: #0ea5e9; + --info-hover: #bae6fd; + --info-color: #3b82f6; + --info-color-dark: #2563eb; + + /* 中性灰色 */ + --neutral-100: #f8f9fa; + --neutral-200: #e9ecef; + --neutral-300: #dee2e6; + --neutral-400: #adb5bd; + --neutral-500: #6c757d; + --neutral-600: #495057; + --neutral-700: #2c3e50; + --neutral-800: #8b95a5; + --neutral-alt: #f1f3f4; + + /* 日志颜色 */ + --log-time: #858585; + --log-info: #4ec9b0; + --log-error: #f48771; + --log-warn: #dcdcaa; + + /* 按钮颜色 */ + --btn-success: #28a745; + --btn-success-secondary: #20c997; + --btn-primary-hover: #047857; +} + +/* CSS变量 - 暗黑主题 */ +[data-theme="dark"] { + --primary-color: #34d399; + --primary-hover: #10b981; + --primary-light: #6ee7b7; + + --secondary-color: #34d399; + --success-color: #34d399; + --danger-color: #f87171; + --warning-color: #fbbf24; + --info-color: #60a5fa; + + --bg-primary: #0f172a; + --bg-secondary: #1e293b; + --bg-tertiary: #334155; + --bg-glass: rgba(15, 23, 42, 0.8); + --bg-glass-strong: rgba(15, 23, 42, 0.95); + + --text-primary: #f8fafc; + --text-secondary: #cbd5e1; + --text-tertiary: #64748b; + + --border-color: #334155; + --border-hover: #475569; + + --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); + --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.6); + --shadow-glass: 0 8px 32px 0 rgba(0, 0, 0, 0.3); + + /* 辅助色透明度版本 - 暗色 */ + --primary-10: rgba(16, 185, 129, 0.1); + --primary-20: rgba(16, 185, 129, 0.2); + --primary-30: rgba(16, 185, 129, 0.3); + --primary-40: rgba(16, 185, 129, 0.4); + + --success-10: rgba(16, 185, 129, 0.1); + + --warning-15: rgba(251, 191, 36, 0.15); + --warning-20: rgba(251, 191, 36, 0.2); + --warning-25: rgba(251, 191, 36, 0.25); + --warning-30: rgba(251, 191, 36, 0.3); + --warning-40: rgba(251, 191, 36, 0.4); + --warning-50: rgba(251, 191, 36, 0.5); + + --danger-30: rgba(248, 113, 113, 0.3); + --danger-40: rgba(248, 113, 113, 0.4); + --danger-70: rgba(248, 113, 113, 0.7); + + /* 基础颜色 */ + --white: #ffffff; + --black: #000000; + + /* 遮罩背景 */ + --overlay-bg: rgba(0, 0, 0, 0.8); + + /* 代码块和日志区域 */ + --code-bg: #0d1117; + --code-text: #e6edf3; + + /* 主题切换按钮 */ + --theme-toggle-bg: var(--bg-tertiary); + --theme-toggle-icon: #fbbf24; + + /* 警告/高亮颜色 */ + --warning-bg: #78350f; + --warning-bg-light: #92400e; + --warning-bg-dark: #78350f; + --warning-border: #b45309; + --warning-text: #fef3c7; + --warning-text-dark: #fde68a; + --warning-text-darker: #fde68a; + --warning-bg-alt: #78350f; + + /* 成功/健康颜色 */ + --success-bg: #064e3b; + --success-bg-light: #065f46; + --success-bg-alt: #064e3b; + --success-text: #6ee7b7; + --success-text-light: #6ee7b7; + + /* 错误/危险颜色 */ + --danger-bg: #7f1d1d; + --danger-bg-light: #7f1d1d; + --danger-bg-alt: #7f1d1d; + --danger-bg-medium: #991b1b; + --danger-border: #dc2626; + --danger-border-light: #dc2626; + --danger-border-dark: #dc2626; + --danger-text: #fca5a5; + --danger-text-light: #fecaca; + --danger-text-dark: #fecaca; + --danger-icon: #fca5a5; + --danger-label: #fca5a5; + --danger-alt: #dc2626; + --danger-secondary: #dc2626; + + /* 信息/蓝色颜色 */ + --info-bg: #1e3a5f; + --info-bg-light: #1e3a5f; + --info-bg-lighter: #1e3a5f; + --info-bg-alt: #1e3a5f; + --info-text: #93c5fd; + --info-text-dark: #93c5fd; + --info-text-darker: #93c5fd; + --info-border: #3b82f6; + --info-hover: #1e3a5f; + --info-color: #3b82f6; + --info-color-dark: #3b82f6; + + /* 中性灰色 */ + --neutral-100: var(--bg-secondary); + --neutral-200: var(--border-color); + --neutral-300: var(--border-color); + --neutral-400: var(--text-secondary); + --neutral-500: var(--text-secondary); + --neutral-600: var(--text-primary); + --neutral-700: var(--text-primary); + --neutral-800: var(--text-secondary); + --neutral-alt: var(--bg-tertiary); + + /* 日志颜色 */ + --log-time: #858585; + --log-info: #4ec9b0; + --log-error: #f48771; + --log-warn: #dcdcaa; + + /* 按钮颜色 */ + --btn-success: #28a745; + --btn-success-secondary: #20c997; + --btn-primary-hover: #047857; +} + +/* 基础样式 */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + background: var(--bg-secondary); + color: var(--text-primary); + line-height: 1.6; + background-image: + radial-gradient(at 0% 0%, rgba(var(--primary-rgb), 0.05) 0px, transparent 50%), + radial-gradient(at 100% 0%, rgba(var(--indigo-rgb), 0.05) 0px, transparent 50%); +} + +/* 滚动条美化 */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--text-tertiary); +} + +/* 容器 */ +.container { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +/* 主要内容区域 */ +.main-content { + display: flex; + flex: 1; + max-width: 1600px; + width: 100%; + margin: 0 auto; + padding: 1.5rem; + gap: 1.5rem; +} + +/* 内容区域 */ +.content { + flex: 1; + padding: 0; + overflow-x: hidden; +} + +.section { + display: none; +} + +.section.active { + display: block; +} + +.section h2 { + font-size: 1.5rem; + font-weight: 700; + margin-bottom: 1.5rem; + color: var(--text-primary); + letter-spacing: -0.025em; + display: flex; + align-items: center; + gap: 0.75rem; +} + +/* 按钮基础样式 */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.625rem 1.25rem; + border: 1px solid transparent; + border-radius: var(--radius-lg); + font-size: 0.875rem; + font-weight: 600; + cursor: pointer; + transition: var(--transition); + text-decoration: none; + line-height: 1.25; +} + +.btn:active { + transform: scale(0.98); +} + +.btn-primary { + background: var(--primary-color); + color: #ffffff; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); +} + +.btn-primary:hover { + background: var(--primary-hover); + box-shadow: 0 4px 6px -1px var(--primary-20); +} + +.btn-secondary { + background: var(--bg-primary); + color: var(--text-primary); + border-color: var(--border-color); +} + +.btn-secondary:hover { + background: var(--bg-secondary); + border-color: var(--text-secondary); +} + +.btn-success { + background: var(--success-color); + color: white; +} + +.btn-success:hover { + filter: brightness(1.1); + box-shadow: 0 4px 6px -1px var(--success-10); +} + +.btn-danger { + background: var(--bg-primary); + color: var(--danger-color); + border-color: var(--danger-border); +} + +.btn-danger:hover { + background: var(--danger-bg); + border-color: var(--danger-color); +} + +.btn-small { + padding: 8px 12px; + font-size: 12px; + border: none; + border-radius: 0.375rem; + cursor: pointer; + display: flex; + align-items: center; + gap: 6px; + font-weight: 500; + transition: all 0.3s ease; +} + +.btn-sm { + padding: 4px 10px; + font-size: 12px; +} + +/* 文本辅助类 */ +.text-success { color: var(--success-color) !important; } +.text-warning { color: var(--warning-color) !important; } +.text-danger { color: var(--danger-color) !important; } + +/* 动画 */ +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes slideIn { + from { transform: scale(0.95); opacity: 0; } + to { transform: scale(1); opacity: 1; } +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +/* 主题切换动画 */ +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; +} + +/* 通用通知容器 */ +.toast-container { + position: fixed; + top: 1rem; + right: 1rem; + z-index: 1001; + display: flex; + flex-direction: column; + gap: 0.5rem; + pointer-events: none; +} + +.toast { + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 0.5rem; + padding: 1rem 1.5rem; + box-shadow: var(--shadow-lg); + min-width: 300px; + animation: slideIn 0.3s ease; + pointer-events: auto; +} + +.toast.success { border-left: 4px solid var(--success-color); } +.toast.error { border-left: 4px solid var(--danger-color); } + +/* 复选框和单选框通用样式 */ +.checkbox-item, .radio-label { + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; +} + +.form-control { + width: 100%; + padding: 0.75rem 1rem; + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + font-size: 0.9rem; + transition: var(--transition); + background: var(--bg-secondary); + color: var(--text-primary); +} + +.form-control:focus { + outline: none; + border-color: var(--primary-color); + background: var(--bg-primary); + box-shadow: 0 0 0 4px var(--primary-10); +} + +/* 主题切换按钮 */ +.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 .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; } + +/* 语言切换器通用部分 */ +.language-switcher { + position: relative; + display: inline-block; +} + +.language-btn { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + background: transparent; + color: var(--text-secondary); + border: 1px solid var(--border-color); + border-radius: 0.375rem; + cursor: pointer; + font-size: 0.875rem; + font-weight: 500; + transition: var(--transition); +} + +.language-btn:hover { + background: var(--bg-tertiary); + color: var(--primary-color); + border-color: var(--primary-color); + transform: translateY(-1px); +} + +.language-dropdown { + position: absolute; + top: calc(100% + 0.5rem); + right: 0; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 0.5rem; + box-shadow: var(--shadow-lg); + min-width: 150px; + opacity: 0; + visibility: hidden; + transform: translateY(-10px); + transition: all 0.3s ease; + z-index: 1000; +} + +.language-dropdown.show { + opacity: 1; + visibility: visible; + transform: translateY(0); +} + +.language-option { + display: flex; + align-items: center; + gap: 0.75rem; + width: 100%; + padding: 0.75rem 1rem; + background: none; + border: none; + text-align: left; + cursor: pointer; + font-size: 0.875rem; + color: var(--text-primary); + transition: var(--transition); +} + +.language-option:hover { + background: var(--bg-secondary); +} + +.language-option.active i { + opacity: 1; +} + +.language-option i { + font-size: 0.875rem; + color: var(--primary-color); + opacity: 0; + transition: opacity 0.3s ease; +} + +.status-used { + background: var(--success-bg); + color: var(--success-text); +} + +.status-unused { + background: var(--warning-bg); + color: var(--warning-text); +} + +.status-invalid { + background: var(--danger-bg); + color: var(--danger-text); +} + +.status-success { color: var(--success-color); } +.status-error { color: var(--danger-color); } + +/* 响应式调整通用部分 */ +@media (max-width: 768px) { + .main-content { + flex-direction: column; + } + + .form-row { + grid-template-columns: 1fr; + } +} diff --git a/static/app/component-loader.js b/static/app/component-loader.js new file mode 100644 index 0000000..116d13c --- /dev/null +++ b/static/app/component-loader.js @@ -0,0 +1,137 @@ +/** + * 组件加载器 - 用于动态加载 HTML 组件片段 + * Component Loader - For dynamically loading HTML component fragments + */ + +// 组件缓存 +const componentCache = new Map(); + +/** + * 加载单个组件 + * @param {string} componentPath - 组件文件路径 + * @returns {Promise} - 组件 HTML 内容 + */ +async function loadComponent(componentPath) { + // 检查缓存 + if (componentCache.has(componentPath)) { + return componentCache.get(componentPath); + } + + try { + const response = await fetch(componentPath); + if (!response.ok) { + throw new Error(`Failed to load component: ${componentPath} (${response.status})`); + } + const html = await response.text(); + // 缓存组件 + componentCache.set(componentPath, html); + return html; + } catch (error) { + console.error(`Error loading component ${componentPath}:`, error); + throw error; + } +} + +/** + * 将组件插入到指定容器 + * @param {string} componentPath - 组件文件路径 + * @param {string|HTMLElement} container - 容器选择器或元素 + * @param {string} position - 插入位置: 'replace', 'append', 'prepend', 'beforeend', 'afterbegin' + * @returns {Promise} + */ +async function insertComponent(componentPath, container, position = 'beforeend') { + const html = await loadComponent(componentPath); + + const containerElement = typeof container === 'string' + ? document.querySelector(container) + : container; + + if (!containerElement) { + throw new Error(`Container not found: ${container}`); + } + + if (position === 'replace') { + containerElement.innerHTML = html; + } else { + containerElement.insertAdjacentHTML(position, html); + } +} + +/** + * 批量加载多个组件 + * @param {Array<{path: string, container: string, position?: string}>} components - 组件配置数组 + * @returns {Promise} + */ +async function loadComponents(components) { + const promises = components.map(({ path, container, position }) => + insertComponent(path, container, position) + ); + await Promise.all(promises); +} + +/** + * 初始化页面组件 + * 加载所有页面组件并插入到相应位置 + * @returns {Promise} + */ +async function initializeComponents() { + const basePath = 'components/'; + + // 定义组件配置 + const componentConfigs = [ + { path: `${basePath}header.html`, container: '.container', position: 'afterbegin' }, + { path: `${basePath}sidebar.html`, container: '#sidebar-container', position: 'replace' }, + { path: `${basePath}section-dashboard.html`, container: '#content-container', position: 'beforeend' }, + { path: `${basePath}section-config.html`, container: '#content-container', position: 'beforeend' }, + { path: `${basePath}section-upload-config.html`, container: '#content-container', position: 'beforeend' }, + { path: `${basePath}section-providers.html`, container: '#content-container', position: 'beforeend' }, + { path: `${basePath}section-usage.html`, container: '#content-container', position: 'beforeend' }, + { path: `${basePath}section-logs.html`, container: '#content-container', position: 'beforeend' }, + { path: `${basePath}section-plugins.html`, container: '#content-container', position: 'beforeend' }, + ]; + + try { + // 首先加载 header + await insertComponent(`${basePath}header.html`, '.container', 'afterbegin'); + + // 然后加载 sidebar + await insertComponent(`${basePath}sidebar.html`, '#sidebar-container', 'replace'); + + // 最后加载所有 section 组件 + const sectionComponents = [ + { path: `${basePath}section-dashboard.html`, container: '#content-container', position: 'beforeend' }, + { path: `${basePath}section-config.html`, container: '#content-container', position: 'beforeend' }, + { path: `${basePath}section-upload-config.html`, container: '#content-container', position: 'beforeend' }, + { path: `${basePath}section-providers.html`, container: '#content-container', position: 'beforeend' }, + { path: `${basePath}section-usage.html`, container: '#content-container', position: 'beforeend' }, + { path: `${basePath}section-logs.html`, container: '#content-container', position: 'beforeend' }, + { path: `${basePath}section-plugins.html`, container: '#content-container', position: 'beforeend' }, + ]; + + await loadComponents(sectionComponents); + + console.log('All components loaded successfully'); + // 触发组件加载完成事件 + window.dispatchEvent(new CustomEvent('componentsLoaded')); + + } catch (error) { + console.error('Failed to initialize components:', error); + throw error; + } +} + +/** + * 清除组件缓存 + */ +function clearComponentCache() { + componentCache.clear(); +} + +// 导出函数 +export { + loadComponent, + insertComponent, + loadComponents, + initializeComponents, + clearComponentCache +}; \ No newline at end of file diff --git a/static/app/constants.js b/static/app/constants.js index 73a9733..eeb300d 100644 --- a/static/app/constants.js +++ b/static/app/constants.js @@ -14,19 +14,19 @@ let providerStats = { providerTypeStats: {} // 详细按类型统计 }; -// DOM元素 +// DOM元素 - 使用 getter 延迟获取,以支持动态加载的组件 const elements = { - serverStatus: document.getElementById('serverStatus'), - restartBtn: document.getElementById('restartBtn'), - sections: document.querySelectorAll('.section'), - navItems: document.querySelectorAll('.nav-item'), - logsContainer: document.getElementById('logsContainer'), - clearLogsBtn: document.getElementById('clearLogs'), - toggleAutoScrollBtn: document.getElementById('toggleAutoScroll'), - saveConfigBtn: document.getElementById('saveConfig'), - resetConfigBtn: document.getElementById('resetConfig'), - toastContainer: document.getElementById('toastContainer'), - modelProvider: document.getElementById('modelProvider'), + get serverStatus() { return document.getElementById('serverStatus'); }, + get restartBtn() { return document.getElementById('restartBtn'); }, + get sections() { return document.querySelectorAll('.section'); }, + get navItems() { return document.querySelectorAll('.nav-item'); }, + get logsContainer() { return document.getElementById('logsContainer'); }, + get clearLogsBtn() { return document.getElementById('clearLogs'); }, + get toggleAutoScrollBtn() { return document.getElementById('toggleAutoScroll'); }, + get saveConfigBtn() { return document.getElementById('saveConfig'); }, + get resetConfigBtn() { return document.getElementById('resetConfig'); }, + get toastContainer() { return document.getElementById('toastContainer'); }, + get modelProvider() { return document.getElementById('modelProvider'); }, }; // 定期刷新间隔 diff --git a/static/app/mobile.css b/static/app/mobile.css index b30061e..48b718c 100644 --- a/static/app/mobile.css +++ b/static/app/mobile.css @@ -28,17 +28,24 @@ /* 移动端汉堡菜单按钮 */ .mobile-menu-toggle { display: none; - background: none; - border: none; - font-size: 1.5rem; - color: var(--primary-color); + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + font-size: 1.25rem; + color: var(--text-primary); cursor: pointer; padding: 0.5rem; transition: var(--transition); + width: 40px; + height: 40px; + align-items: center; + justify-content: center; } .mobile-menu-toggle:hover { - color: var(--secondary-color); + background: var(--bg-secondary); + border-color: var(--primary-color); + color: var(--primary-color); } /* 移动端覆盖层 */ @@ -60,12 +67,23 @@ opacity: 1; } +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + /* ======================================== 平板设备适配 (768px - 1024px) ======================================== */ @media (max-width: 1024px) { .header-content { - padding: 1rem 1.5rem; + padding: 0.75rem 1.5rem; } .content { @@ -80,9 +98,14 @@ .config-form { max-width: 100%; } +} - .logout-btn { - line-height: 0; +/* ======================================== + 桌面端确保header-controls显示 (最小宽度 769px) + ======================================== */ +@media (min-width: 769px) { + .header .header-controls { + display: flex !important; } } @@ -90,63 +113,218 @@ 移动端适配 (最大宽度 768px) ======================================== */ @media (max-width: 768px) { - /* Header 优化 */ + /* Header 重新设计 - 两行布局 */ .header-content { - padding: 0.75rem 1rem; + padding: 0.625rem 1rem; flex-wrap: wrap; - gap: 0.75rem; - justify-content: center; + gap: 0.5rem; + justify-content: space-between; + align-items: center; } .header h1 { - font-size: 1.25rem; - flex: 1 1 100%; + font-size: 1rem; + flex: 1 1 auto; min-width: 0; - text-align: center; - order: -1; + text-align: left; + order: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: calc(100% - 50px); } .header h1 i { - margin-right: 0.25rem; + margin-right: 0.375rem; + font-size: 0.875rem; + flex-shrink: 0; } - .header-controls { + .header-title { + display: inline-block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; + } + + /* 显示汉堡菜单按钮 */ + .mobile-menu-toggle { + display: inline-flex !important; + order: 2; + z-index: 101; + position: relative; + } + + /* 默认隐藏header-controls */ + .header .header-controls { + display: none !important; gap: 0.5rem; flex-wrap: wrap; - justify-content: center; - flex: 1 1 100%; + justify-content: flex-end; + flex: 0 0 auto; + order: 3; + width: 100%; + z-index: 100; + position: relative; } - .status-badge { - padding: 0.375rem 0.75rem; + /* 当通过JS设置display: flex时显示 */ + .header .header-controls[style*="display: flex"] { + display: flex !important; + animation: slideDown 0.3s ease; + background: var(--bg-glass); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + padding: 0.75rem; + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + border: 1px solid var(--border-color); + margin-top: 0.5rem; + gap: 0.5rem; + justify-content: center; + align-items: center; + } + + /* 统一header-controls内所有元素的大小 */ + .header .header-controls[style*="display: flex"] > * { + flex: 0 0 auto; + } + + /* 状态徽章 */ + .header .header-controls[style*="display: flex"] .status-badge { + padding: 0.5rem 0.75rem; font-size: 0.75rem; + border-radius: var(--radius-full); + display: inline-flex; + align-items: center; + gap: 0.375rem; + } + + /* GitHub链接 */ + .header .header-controls[style*="display: flex"] .github-link { + width: 40px; + height: 40px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + } + + /* 主题切换按钮 */ + .header .header-controls[style*="display: flex"] .theme-toggle { + width: 40px; + height: 40px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + } + + /* 登出按钮 - 与语言切换按钮样式一致 */ + .header .header-controls[style*="display: flex"] .logout-btn { + padding: 0.5rem 1rem; + background: transparent; + color: var(--text-secondary); + border: 1px solid var(--border-color); + border-radius: 0.375rem; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: var(--transition); + } + + .header .header-controls[style*="display: flex"] .logout-btn:hover { + background: var(--bg-tertiary); + color: var(--primary-color); + border-color: var(--primary-color); + transform: translateY(-1px); + } + + .header .header-controls[style*="display: flex"] .logout-btn span { + display: inline; + } + + /* 重启按钮 - 与语言切换按钮样式一致 */ + .header .header-controls[style*="display: flex"] #restartBtn { + padding: 0.5rem 1rem; + background: transparent; + color: var(--text-secondary); + border: 1px solid var(--border-color); + border-radius: 0.375rem; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: var(--transition); + } + + .header .header-controls[style*="display: flex"] #restartBtn:hover { + background: var(--bg-tertiary); + color: var(--primary-color); + border-color: var(--primary-color); + transform: translateY(-1px); + } + + .header .header-controls[style*="display: flex"] #restartBtn .btn-text { + display: inline; + } + + /* 隐藏次要元素 */ + .kiro-buy-link { + display: none; + } + + /* 状态徽章 */ + .status-badge { + padding: 0.375rem 0.625rem; + font-size: 0.6875rem; + gap: 0.375rem; + } + + .status-badge i { + font-size: 0.5rem; } /* 主内容区域 */ .main-content { flex-direction: column; + gap: 1rem; + padding: 1rem; } - /* 侧边栏移动端样式 */ + /* 侧边栏移动端样式 - 底部导航栏 */ .sidebar { width: 100%; - border-right: none; - border-bottom: 1px solid var(--border-color); - padding: 0; + border: none; + border-radius: var(--radius-xl); + padding: 0.5rem; position: sticky; - top: 73px; + top: 0; z-index: 90; - background: var(--bg-primary); + background: var(--bg-glass); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + box-shadow: var(--shadow-md); + margin-bottom: 0.5rem; } .sidebar-nav { flex-direction: row; overflow-x: auto; overflow-y: hidden; - padding: 0.5rem 1rem; - gap: 0.5rem; + padding: 0.25rem; + gap: 0.375rem; -webkit-overflow-scrolling: touch; scrollbar-width: none; + justify-content: flex-start; } .sidebar-nav::-webkit-scrollbar { @@ -154,97 +332,104 @@ } .nav-item { - padding: 0.625rem 1rem; + padding: 0.5rem 0.75rem; white-space: nowrap; - border-radius: 0.375rem; - border-right: none; - font-size: 0.875rem; + border-radius: var(--radius-md); + font-size: 0.75rem; cursor: pointer; user-select: none; -webkit-user-select: none; -webkit-tap-highlight-color: rgba(0, 0, 0, 0.1); touch-action: manipulation; - } - - .nav-item.active { - background: var(--primary-color) !important; - color: white !important; - border-right: none; - } - - .nav-item:active { - opacity: 0.8; - transform: scale(0.98); + gap: 0.375rem; + min-width: fit-content; + flex-shrink: 0; } .nav-item i { - width: 16px; + width: 14px; font-size: 0.875rem; pointer-events: none; } .nav-item span { pointer-events: none; + font-weight: 500; + } + + .nav-item.active { + background: var(--primary-color) !important; + color: white !important; + box-shadow: 0 2px 8px var(--primary-30); + } + + .nav-item:active { + opacity: 0.8; + transform: scale(0.96); } /* 内容区域 */ .content { - padding: 1rem; + padding: 0; } .section h2 { - font-size: 1.5rem; + font-size: 1.25rem; margin-bottom: 1rem; + padding: 0 0.25rem; } /* 统计卡片 */ .stats-grid { - grid-template-columns: 1fr; - gap: 1rem; + grid-template-columns: repeat(2, 1fr); + gap: 0.75rem; } .stat-card { - padding: 1rem; + padding: 0.875rem; + border-radius: var(--radius-lg); } .stat-icon { - width: 48px; - height: 48px; - font-size: 1.25rem; + width: 40px; + height: 40px; + font-size: 1.125rem; } .stat-info h3 { - font-size: 1.5rem; + font-size: 1.25rem; } .stat-info p { - font-size: 0.8125rem; + font-size: 0.75rem; } /* 表单优化 */ .config-panel { padding: 1rem; - border-radius: 0.375rem; + border-radius: var(--radius-lg); } .form-row, .config-row { grid-template-columns: 1fr; - gap: 1rem; + gap: 0.875rem; } .form-group { - margin-bottom: 1rem; + margin-bottom: 0.875rem; } .form-group label { - font-size: 0.875rem; + font-size: 0.8125rem; margin-bottom: 0.375rem; + font-weight: 500; } .form-control { - padding: 0.625rem; + padding: 0.625rem 0.875rem; font-size: 0.875rem; + border-radius: var(--radius-md); } textarea.form-control { @@ -254,13 +439,14 @@ /* 按钮优化 */ .btn { padding: 0.625rem 1rem; - font-size: 0.875rem; + font-size: 0.8125rem; + border-radius: var(--radius-md); } .form-actions { display: flex; flex-direction: column; - gap: 0.75rem; + gap: 0.625rem; } .form-actions .btn { @@ -270,31 +456,45 @@ /* 单选按钮组 */ .radio-group { flex-direction: column; - gap: 0.75rem; + gap: 0.625rem; } /* 高级配置区域 */ .advanced-config-section { padding: 1rem; margin-top: 1rem; + border-radius: var(--radius-lg); } .advanced-config-section h3 { - font-size: 1.125rem; - margin-bottom: 1rem; + font-size: 1rem; + margin-bottom: 0.875rem; } /* 系统信息面板 */ .system-info-panel { padding: 1rem; + border-radius: var(--radius-lg); } .system-info-panel h3 { - font-size: 1.125rem; - margin-bottom: 1rem; + font-size: 1rem; + margin-bottom: 0.875rem; + } + + .system-info-header { + flex-direction: column; + align-items: flex-start; + gap: 0.75rem; + } + + .update-controls { + width: 100%; + justify-content: flex-start; } .info-grid { + grid-template-columns: repeat(2, 1fr) !important; gap: 0.75rem; } @@ -302,15 +502,37 @@ padding: 0.75rem; flex-direction: column; align-items: flex-start; - gap: 0.375rem; + gap: 0.5rem; + border-radius: var(--radius-md); + background: var(--bg-secondary); } .info-label { - font-size: 0.8125rem; + font-size: 0.75rem; + font-weight: 500; + color: var(--text-secondary); + } + + .info-label i { + font-size: 0.75rem; + width: 14px; } .info-value { font-size: 0.875rem; + font-weight: 600; + color: var(--text-primary); + } + + .version-display-wrapper { + padding-left: 0; + width: 100%; + } + + .update-badge { + font-size: 0.625rem; + padding: 0.125rem 0.5rem; + margin-left: 0.5rem; } /* 提供商列表 */ @@ -323,31 +545,183 @@ } .provider-card { - padding: 1rem; + padding: 0.875rem; + border-radius: var(--radius-lg); + } + + /* Provider Item Header 移动端优化 */ + .provider-item-header { + padding: 0.875rem; + flex-direction: column; + align-items: flex-start; + gap: 0.75rem; + } + + .provider-item-header .provider-info { + width: 100%; + } + + .provider-item-header .provider-name { + font-size: 1rem; + margin-bottom: 0.5rem; + } + + .provider-item-header .provider-meta { + font-size: 0.75rem; + line-height: 1.3; + } + + .provider-item-header .provider-health-meta { + font-size: 0.6875rem; + margin-top: 0.375rem; + } + + .provider-item-header .provider-actions-group { + width: 100%; + justify-content: flex-start; + flex-wrap: wrap; + gap: 0.5rem; + } + + .provider-item-header .provider-actions-group .btn { + flex: 1; + min-width: 80px; + font-size: 0.75rem; + padding: 0.5rem 0.75rem; + } + + .provider-item-header .health-status { + font-size: 0.6875rem; + padding: 0.1875rem 0.5rem; + } + + .provider-item-header .disabled-status { + font-size: 0.6875rem; + padding: 0.1875rem 0.5rem; + } + + .provider-item-header .provider-error-info { + padding: 0.5rem 0.625rem; + font-size: 0.6875rem; + margin-top: 0.5rem; + } + + .provider-item-header .provider-error-info i { + font-size: 0.75rem; + } + + .provider-item-header .provider-error-info .error-label { + font-size: 0.6875rem; + } + + .provider-item-header .provider-error-info .error-message { + font-size: 0.6875rem; } .provider-header { flex-direction: column; align-items: flex-start; - gap: 0.75rem; + gap: 0.625rem; + } + + .provider-header-left { + width: 100%; + margin-bottom: 0.25rem; + } + + .provider-name { + font-size: 1rem; + font-weight: 600; + margin-bottom: 0.25rem; + } + + .provider-status { + font-size: 0.75rem; + padding: 0.25rem 0.5rem; + } + + .provider-header-right { + width: 100%; + justify-content: flex-start; + gap: 0.5rem; } .provider-actions { width: 100%; justify-content: flex-start; gap: 0.5rem; + margin-bottom: 0.5rem; + } + + .provider-actions-group { + flex-wrap: wrap; + gap: 0.5rem; + width: 100%; } .provider-actions .btn { flex: 1; min-width: 0; + font-size: 0.8125rem; + padding: 0.5rem 0.75rem; + } + + .provider-summary { + padding: 0.75rem; + margin-top: 0.5rem; + background: var(--bg-secondary); + border-radius: var(--radius-md); + } + + .provider-summary h4 { + font-size: 0.875rem; + margin-bottom: 0.5rem; + } + + .provider-summary p { + font-size: 0.75rem; + line-height: 1.4; + } + + .provider-models { + font-size: 0.75rem; + margin-top: 0.5rem; + } + + .provider-models span { + display: inline-block; + padding: 0.125rem 0.375rem; + margin: 0.125rem; + font-size: 0.6875rem; + } + + /* 提供商统计信息 */ + .provider-stats { + grid-template-columns: repeat(2, 1fr) !important; + gap: 0.75rem; + margin-top: 0.75rem; + } + + .provider-stat { + padding: 0.625rem; + background: var(--bg-secondary); + border-radius: var(--radius-md); + } + + .provider-stat-label { + font-size: 0.6875rem; + margin-bottom: 0.375rem; + } + + .provider-stat-value { + font-size: 1rem; } /* 提供商详情 */ .provider-summary { flex-direction: column; align-items: flex-start; - gap: 1rem; + gap: 0.875rem; } .provider-summary-actions { @@ -366,14 +740,29 @@ max-height: 90vh; margin: 5vh auto; padding: 1rem; + border-radius: var(--radius-xl); } .provider-modal-header { padding: 1rem; margin: -1rem -1rem 1rem -1rem; + border-bottom: 1px solid var(--border-color); + flex-direction: column; + align-items: flex-start; + gap: 0.75rem; } .provider-modal-header h2 { + font-size: 1.125rem; + flex: 1; + } + + .provider-modal-header .modal-close { + position: absolute; + top: 1rem; + right: 1rem; + width: 32px; + height: 32px; font-size: 1.25rem; } @@ -382,36 +771,110 @@ max-height: calc(90vh - 140px); } + .provider-summary { + padding: 0.875rem; + margin-bottom: 0.875rem; + flex-direction: column; + align-items: flex-start; + gap: 0.75rem; + } + + .provider-summary-item { + padding: 0.5rem; + width: 100%; + } + + .provider-summary-item .label { + font-size: 0.75rem; + margin-bottom: 0.375rem; + } + + .provider-summary-item .value { + font-size: 1.125rem; + } + + .provider-summary-actions { + width: 100%; + margin-left: 0; + flex-wrap: wrap; + gap: 0.5rem; + } + + .provider-summary-actions .btn { + flex: 1; + min-width: 120px; + } + .config-section { - padding: 1rem; - margin-bottom: 1rem; + padding: 0.875rem; + margin-bottom: 0.875rem; + border-radius: var(--radius-md); } .config-section h3 { - font-size: 1.125rem; + font-size: 1rem; margin-bottom: 0.75rem; } .config-item { - padding: 0.75rem; + padding: 0.625rem; gap: 0.5rem; + flex-direction: column; + align-items: flex-start; + } + + .config-item label { + font-size: 0.8125rem; + margin-bottom: 0.375rem; + } + + .config-item input, + .config-item textarea, + .config-item select { + padding: 0.625rem; + font-size: 0.875rem; } .config-label { - font-size: 0.8125rem; - min-width: 80px; + font-size: 0.75rem; + min-width: 70px; + font-weight: 500; } .config-value { - font-size: 0.875rem; + font-size: 0.8125rem; + } + + .form-grid { + grid-template-columns: 1fr; + gap: 0.75rem; + } + + .form-group { + margin-bottom: 0.75rem; + } + + .form-group label { + font-size: 0.8125rem; + margin-bottom: 0.375rem; + } + + .form-actions { + flex-direction: column; + gap: 0.625rem; + } + + .form-actions .btn { + width: 100%; } /* 日志容器 */ .logs-container { height: calc(100vh - 280px); min-height: 300px; - font-size: 0.75rem; - padding: 0.75rem; + font-size: 0.6875rem; + padding: 0.625rem; + border-radius: var(--radius-lg); } .logs-controls { @@ -422,18 +885,20 @@ .logs-controls .btn { flex: 1; - min-width: 120px; + min-width: 100px; + font-size: 0.75rem; } .log-entry { - padding: 0.5rem; - margin-bottom: 0.375rem; - font-size: 0.75rem; + padding: 0.375rem; + margin-bottom: 0.25rem; + font-size: 0.6875rem; line-height: 1.4; + border-radius: var(--radius-sm); } .log-timestamp { - font-size: 0.6875rem; + font-size: 0.625rem; } /* Toast 通知 */ @@ -446,8 +911,8 @@ .toast { padding: 0.75rem 1rem; - font-size: 0.875rem; - border-radius: 0.375rem; + font-size: 0.8125rem; + border-radius: var(--radius-md); pointer-events: auto; } @@ -463,7 +928,7 @@ /* 切换开关 */ .toggle-switch { - transform: scale(0.9); + transform: scale(0.85); } /* OAuth刷新切换 */ @@ -478,14 +943,15 @@ grid-template-columns: 1fr; } - /* 路径路由示例移动端优化 */ + /* 路由路由示例移动端优化 */ .routing-examples-panel { padding: 1rem; margin-bottom: 1rem; + border-radius: var(--radius-lg); } .routing-examples-panel h3 { - font-size: 1.125rem; + font-size: 1rem; } .routing-description { @@ -494,11 +960,11 @@ .routing-examples-grid { grid-template-columns: 1fr; - gap: 1rem; + gap: 0.875rem; } .routing-example-card { - border-radius: 0.375rem; + border-radius: var(--radius-lg); } .routing-card-header { @@ -508,97 +974,192 @@ } .routing-card-header h4 { - font-size: 0.9rem; + font-size: 0.875rem; flex-basis: 100%; margin-bottom: 0.25rem; } .routing-card-header i { - font-size: 1rem; + font-size: 0.875rem; } .provider-badge { - font-size: 0.7rem; - padding: 0.2rem 0.4rem; + font-size: 0.625rem; + padding: 0.1875rem 0.375rem; } .routing-card-content { - padding: 1rem; + padding: 0.875rem; } .endpoint-path { - font-size: 0.75rem; - padding: 0.4rem 0.6rem; + font-size: 0.6875rem; + padding: 0.375rem 0.5rem; padding-right: 2rem; word-break: break-all; + border-radius: var(--radius-sm); } .copy-btn { - right: 0.4rem; - padding: 0.2rem; - min-width: 32px; - min-height: 32px; + right: 0.375rem; + padding: 0.1875rem; + min-width: 28px; + min-height: 28px; } .usage-example pre { - font-size: 0.7rem; - padding: 0.75rem; + font-size: 0.6875rem; + padding: 0.625rem; overflow-x: auto; white-space: pre-wrap; + border-radius: var(--radius-md); } .routing-tips { - padding: 1rem; + padding: 0.875rem; + border-radius: var(--radius-md); } .routing-tips h4 { - font-size: 0.9rem; + font-size: 0.875rem; } .routing-tips ul { - padding-left: 1.25rem; + padding-left: 1.125rem; } .routing-tips li { - font-size: 0.8rem; + font-size: 0.75rem; margin-bottom: 0.5rem; } .routing-tips code { - font-size: 0.7rem; - padding: 0.1rem 0.2rem; + font-size: 0.6875rem; + padding: 0.125rem 0.25rem; } /* 协议标签移动端优化 */ .protocol-tabs { margin-bottom: 0.75rem; + gap: 0.375rem; } .protocol-tab { - padding: 0.5rem 0.75rem; - font-size: 0.8rem; + padding: 0.375rem 0.625rem; + font-size: 0.75rem; + border-radius: var(--radius-md); } /* 池配置部分 */ .pool-section { - margin-top: 1rem; + margin-top: 0.875rem; } .pool-section small { - font-size: 0.75rem; + font-size: 0.6875rem; } /* 系统提示部分 */ .system-prompt-section { - margin-top: 1rem; + margin-top: 0.875rem; } .system-prompt-section textarea { min-height: 120px; } - .logout-btn { - line-height: 0; + /* Contact Grid 移动端优化 */ + .contact-grid { + grid-template-columns: 1fr !important; + gap: 1rem; + margin-top: 1rem; + } + + .contact-card { + padding: 1rem; + border-radius: var(--radius-lg); + } + + .contact-card h3 { + font-size: 1rem; + margin-bottom: 0.75rem; + gap: 0.375rem; + } + + .contact-card h3 i { + font-size: 0.875rem; + } + + .qr-container { + margin: 1rem 0; + } + + .qr-code { + width: 150px; + height: 150px; + border-width: 2px; + padding: 0.375rem; + } + + .qr-description { + font-size: 0.75rem; + line-height: 1.4; + } + + /* Config Stats 移动端优化 */ + .config-stats { + flex-wrap: wrap; + gap: 0.5rem; + justify-content: flex-start; + } + + .config-stats span { + font-size: 0.75rem; + padding: 0.1875rem 0.5rem; + border-radius: 0.375rem; + } + + /* Dashboard Top Row 移动端优化 */ + .dashboard-top-row { + flex-direction: column; + gap: 1rem; + margin-bottom: 1rem; + } + + .dashboard-top-row .stats-grid { + margin-bottom: 0; + } + + .dashboard-top-row .dashboard-contact { + margin-top: 0; + padding-top: 0; + border-top: none; + } + + .dashboard-top-row .dashboard-contact .contact-grid { + grid-template-columns: 1fr !important; + gap: 0.75rem; + } + + .dashboard-top-row .dashboard-contact .contact-card { + padding: 0.875rem; + } + + .dashboard-top-row .dashboard-contact .qr-container { + margin: 0.5rem 0; + } + + .dashboard-top-row .dashboard-contact .qr-code { + width: 120px; + height: 120px; + } + + .dashboard-top-row .dashboard-contact .contact-card h3 { + font-size: 0.875rem; + } + + .dashboard-top-row .dashboard-contact .qr-description { + font-size: 0.6875rem; } } @@ -607,44 +1168,80 @@ ======================================== */ @media (max-width: 480px) { /* Header 进一步优化 */ + .header-content { + padding: 0.5rem 0.75rem; + gap: 0.5rem; + } + .header h1 { - font-size: 1.125rem; + font-size: 0.875rem; + max-width: calc(100% - 45px); + } + + .header h1 i { + font-size: 0.75rem; + margin-right: 0.25rem; + flex-shrink: 0; } .header-controls { - width: 100%; - justify-content: space-between; + gap: 0.375rem; } .status-badge { - flex: 1; - justify-content: center; + padding: 0.3125rem 0.5rem; + font-size: 0.625rem; } - /* 按钮文字简化 */ - #refreshBtn .fa-sync-alt::after { - content: ''; + /* 按钮进一步缩小 */ + .github-link, + .theme-toggle { + width: 32px; + height: 32px; + font-size: 0.75rem; + } + + .github-link i, + .theme-toggle i { + font-size: 0.75rem; } /* 侧边栏导航 */ + .sidebar { + padding: 0.375rem; + margin-bottom: 0.375rem; + } + + .sidebar-nav { + gap: 0.25rem; + padding: 0.125rem; + } + .nav-item { - padding: 0.5rem 0.75rem; - font-size: 0.8125rem; + padding: 0.375rem 0.625rem; + font-size: 0.6875rem; + gap: 0.25rem; } .nav-item i { - margin-right: 0.375rem; + width: 12px; + font-size: 0.75rem; } /* 统计卡片 */ + .stats-grid { + grid-template-columns: 1fr; + gap: 0.625rem; + } + .stat-icon { - width: 40px; - height: 40px; - font-size: 1.125rem; + width: 36px; + height: 36px; + font-size: 1rem; } .stat-info h3 { - font-size: 1.375rem; + font-size: 1.125rem; } /* 表单控件 */ @@ -667,14 +1264,135 @@ /* 提供商卡片 */ .provider-card { - padding: 0.75rem; + padding: 0.625rem; } .provider-name { - font-size: 1rem; + font-size: 0.875rem; } - /* 模态框 */ + /* 提供商卡片进一步优化 */ + .provider-header { + gap: 0.5rem; + } + + .provider-status { + font-size: 0.6875rem; + padding: 0.1875rem 0.375rem; + } + + .provider-header-right { + gap: 0.375rem; + } + + .provider-actions { + gap: 0.375rem; + margin-bottom: 0.375rem; + } + + .provider-actions-group { + gap: 0.375rem; + } + + .provider-actions .btn { + font-size: 0.75rem; + padding: 0.4375rem 0.625rem; + } + + /* Provider Item Header 进一步优化 */ + .provider-item-header { + padding: 0.625rem; + gap: 0.5rem; + } + + .provider-item-header .provider-name { + font-size: 0.875rem; + margin-bottom: 0.375rem; + } + + .provider-item-header .provider-meta { + font-size: 0.6875rem; + } + + .provider-item-header .provider-health-meta { + font-size: 0.625rem; + margin-top: 0.25rem; + } + + .provider-item-header .provider-actions-group { + gap: 0.375rem; + } + + .provider-item-header .provider-actions-group .btn { + min-width: 70px; + font-size: 0.6875rem; + padding: 0.375rem 0.625rem; + } + + .provider-item-header .health-status { + font-size: 0.625rem; + padding: 0.125rem 0.375rem; + } + + .provider-item-header .disabled-status { + font-size: 0.625rem; + padding: 0.125rem 0.375rem; + } + + .provider-item-header .provider-error-info { + padding: 0.375rem 0.5rem; + font-size: 0.625rem; + margin-top: 0.375rem; + } + + .provider-item-header .provider-error-info i { + font-size: 0.6875rem; + } + + .provider-item-header .provider-error-info .error-label { + font-size: 0.625rem; + } + + .provider-item-header .provider-error-info .error-message { + font-size: 0.625rem; + } + + .provider-summary { + padding: 0.5rem; + margin-top: 0.375rem; + } + + .provider-summary h4 { + font-size: 0.8125rem; + margin-bottom: 0.375rem; + } + + .provider-summary p { + font-size: 0.6875rem; + } + + .provider-models { + font-size: 0.6875rem; + margin-top: 0.375rem; + } + + .provider-models span { + padding: 0.125rem 0.25rem; + margin: 0.0625rem; + font-size: 0.625rem; + } + + /* Config Stats 进一步优化 */ + .config-stats { + gap: 0.375rem; + } + + .config-stats span { + font-size: 0.6875rem; + padding: 0.125rem 0.375rem; + } + + /* 模态框进一步优化 */ .provider-modal-content { width: 98%; max-width: 98%; @@ -684,37 +1402,238 @@ .provider-modal-header { padding: 0.75rem; margin: -0.75rem -0.75rem 0.75rem -0.75rem; + gap: 0.5rem; } .provider-modal-header h2 { - font-size: 1.125rem; + font-size: 1rem; } - .close-modal { - width: 32px; - height: 32px; - font-size: 1.25rem; + .provider-modal-header .modal-close { + width: 28px; + height: 28px; + font-size: 1rem; + top: 0.75rem; + right: 0.75rem; + } + + .provider-summary { + padding: 0.625rem; + margin-bottom: 0.625rem; + gap: 0.5rem; + } + + .provider-summary-item { + padding: 0.375rem; + } + + .provider-summary-item .label { + font-size: 0.6875rem; + margin-bottom: 0.25rem; + } + + .provider-summary-item .value { + font-size: 1rem; + } + + .provider-summary-actions { + gap: 0.375rem; + } + + .provider-summary-actions .btn { + min-width: 100px; + font-size: 0.75rem; + padding: 0.5rem 0.75rem; + } + + .config-section { + padding: 0.625rem; + margin-bottom: 0.625rem; + } + + .config-section h3 { + font-size: 0.875rem; + margin-bottom: 0.625rem; + } + + .config-item { + padding: 0.5rem; + gap: 0.375rem; + } + + .config-item label { + font-size: 0.75rem; + margin-bottom: 0.25rem; + } + + .config-item input, + .config-item textarea, + .config-item select { + padding: 0.5rem; + font-size: 0.8125rem; + } + + .form-grid { + gap: 0.625rem; + } + + .form-group { + margin-bottom: 0.625rem; + } + + .form-group label { + font-size: 0.75rem; + margin-bottom: 0.25rem; + } + + .form-actions { + gap: 0.5rem; + } + + .form-actions .btn { + font-size: 0.75rem; + padding: 0.5rem 0.75rem; } /* 日志 */ .logs-container { - font-size: 0.6875rem; + font-size: 0.625rem; padding: 0.5rem; + height: calc(100vh - 260px); } .log-entry { - padding: 0.375rem; - font-size: 0.6875rem; + padding: 0.3125rem; + font-size: 0.625rem; } /* Toast */ .toast { - font-size: 0.8125rem; - padding: 0.625rem 0.875rem; + font-size: 0.75rem; + padding: 0.625rem 0.75rem; } - .logout-btn { - line-height: 0; + /* Section标题 */ + .section h2 { + font-size: 1.125rem; + } + + /* System Info Panel 进一步优化 */ + .system-info-panel { + padding: 0.875rem; + } + + .system-info-panel h3 { + font-size: 0.875rem; + margin-bottom: 0.75rem; + } + + .system-info-header { + gap: 0.625rem; + } + + .info-grid { + grid-template-columns: 1fr !important; + gap: 0.625rem; + } + + .info-item { + padding: 0.625rem; + gap: 0.375rem; + } + + .info-label { + font-size: 0.6875rem; + } + + .info-label i { + font-size: 0.6875rem; + width: 12px; + } + + .info-value { + font-size: 0.8125rem; + } + + .update-badge { + font-size: 0.5625rem; + padding: 0.125rem 0.375rem; + } + + /* Contact Grid 进一步优化 */ + .contact-grid { + gap: 0.75rem; + } + + .contact-card { + padding: 0.875rem; + } + + .contact-card h3 { + font-size: 0.875rem; + margin-bottom: 0.625rem; + } + + .contact-card h3 i { + font-size: 0.75rem; + } + + .qr-container { + margin: 0.75rem 0; + } + + .qr-code { + width: 120px; + height: 120px; + border-width: 2px; + padding: 0.25rem; + } + + .qr-description { + font-size: 0.6875rem; + line-height: 1.3; + } + + /* Dashboard Top Row 进一步优化 */ + .dashboard-top-row .dashboard-contact .contact-card { + padding: 0.75rem; + } + + .dashboard-top-row .dashboard-contact .qr-container { + margin: 0.375rem 0; + } + + .dashboard-top-row .dashboard-contact .qr-code { + width: 100px; + height: 100px; + } + + .dashboard-top-row .dashboard-contact .contact-card h3 { + font-size: 0.8125rem; + } + + .dashboard-top-row .dashboard-contact .qr-description { + font-size: 0.625rem; + } + + /* Provider Stats 进一步优化 */ + .provider-stats { + grid-template-columns: repeat(2, 1fr) !important; + gap: 0.625rem; + margin-top: 0.625rem; + } + + .provider-stat { + padding: 0.5rem; + } + + .provider-stat-label { + font-size: 0.625rem; + margin-bottom: 0.25rem; + } + + .provider-stat-value { + font-size: 0.875rem; } } @@ -725,14 +1644,15 @@ .sidebar { position: relative; top: 0; + margin-bottom: 0.5rem; } .sidebar-nav { - padding: 0.375rem 1rem; + padding: 0.25rem; } .nav-item { - padding: 0.5rem 0.875rem; + padding: 0.375rem 0.625rem; } .logs-container { @@ -747,10 +1667,6 @@ .provider-modal-body { max-height: calc(85vh - 120px); } - - .logout-btn { - line-height: 0; - } } /* ======================================== @@ -759,12 +1675,13 @@ @media (hover: none) and (pointer: coarse) { /* 增加可点击区域 */ .btn { - min-height: 10px; - padding: 0.5rem; + min-height: 44px; + padding: 0.625rem 1rem; } .nav-item { min-height: 44px; + padding: 0.625rem 0.875rem; } .password-toggle { @@ -772,13 +1689,21 @@ min-height: 44px; } + .github-link, + .theme-toggle, + .logout-btn, + .mobile-menu-toggle { + min-width: 44px; + min-height: 44px; + } + /* 移除悬停效果 */ .nav-item:hover { background: transparent; } .nav-item:active { - background: var(--bg-secondary); + background: var(--bg-tertiary); } .btn:hover { @@ -786,7 +1711,7 @@ } .btn:active { - transform: scale(0.98); + transform: scale(0.96); } /* 优化滚动 */ @@ -795,10 +1720,6 @@ .provider-modal-body { -webkit-overflow-scrolling: touch; } - - .logout-btn { - line-height: 0; - } } /* ======================================== @@ -860,6 +1781,6 @@ } .nav-item.active { - border-right: 4px solid var(--primary-color); + border: 2px solid var(--primary-color); } } \ No newline at end of file diff --git a/static/app/navigation.js b/static/app/navigation.js index 212eac8..2a3b3f9 100644 --- a/static/app/navigation.js +++ b/static/app/navigation.js @@ -27,6 +27,9 @@ function initNavigation() { section.classList.add('active'); } }); + + // 滚动到顶部 + scrollToTop(); }); }); } @@ -51,6 +54,23 @@ function switchToSection(sectionId) { section.classList.add('active'); } }); + + // 滚动到顶部 + scrollToTop(); +} + +/** + * 滚动到页面顶部 + */ +function scrollToTop() { + // 尝试滚动内容区域 + const contentContainer = document.getElementById('content-container'); + if (contentContainer) { + contentContainer.scrollTop = 0; + } + + // 同时滚动窗口到顶部 + window.scrollTo(0, 0); } /** diff --git a/static/app/styles.css b/static/app/styles.css deleted file mode 100644 index a78153a..0000000 --- a/static/app/styles.css +++ /dev/null @@ -1,6035 +0,0 @@ -/* CSS变量 - 亮色主题(默认) */ -:root { - --primary-color: #059669; - --secondary-color: #10b981; - --success-color: #10b981; - --danger-color: #ef4444; - --warning-color: #f59e0b; - --bg-primary: #ffffff; - --bg-secondary: #f9fafb; - --bg-tertiary: #f3f4f6; - --text-primary: #111827; - --text-secondary: #6b7280; - --border-color: #e5e7eb; - --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); - --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; - - /* 品牌色 */ - --indigo-500: #6366f1; - --indigo-600: #4f46e5; - - /* 辅助色透明度版本 */ - --primary-10: rgba(5, 150, 105, 0.1); - --primary-30: rgba(5, 150, 105, 0.3); - --primary-40: rgba(5, 150, 105, 0.4); - - --success-10: rgba(16, 185, 129, 0.1); - - --warning-15: rgba(245, 158, 11, 0.15); - --warning-20: rgba(245, 158, 11, 0.2); - --warning-25: rgba(245, 158, 11, 0.25); - --warning-30: rgba(245, 158, 11, 0.3); - --warning-40: rgba(245, 158, 11, 0.4); - --warning-50: rgba(245, 158, 11, 0.5); - - --danger-30: rgba(220, 53, 69, 0.3); - --danger-40: rgba(220, 53, 69, 0.4); - --danger-70: rgba(220, 53, 69, 0.7); - - --neutral-shadow-sm: rgba(0, 0, 0, 0.05); - --neutral-shadow-md: rgba(0, 0, 0, 0.1); - --neutral-shadow-lg: rgba(0, 0, 0, 0.15); - --neutral-shadow-20: rgba(0, 0, 0, 0.2); - --neutral-shadow-30: rgba(0, 0, 0, 0.3); - --neutral-shadow-40: rgba(0, 0, 0, 0.4); - --neutral-shadow-50: rgba(0, 0, 0, 0.5); - --neutral-shadow-85: rgba(0, 0, 0, 0.85); - --neutral-shadow-95: rgba(0, 0, 0, 0.95); - - --indigo-30: rgba(99, 102, 241, 0.3); - --indigo-40: rgba(99, 102, 241, 0.4); - - --white-20: rgba(255, 255, 255, 0.2); - - /* 基础颜色 */ - --white: #ffffff; - --black: #000000; - - /* 遮罩背景 */ - --overlay-bg: rgba(0, 0, 0, 0.6); - - /* 代码块和日志区域 */ - --code-bg: #1e1e1e; - --code-text: #d4d4d4; - - /* 主题切换按钮 */ - --theme-toggle-bg: var(--bg-tertiary); - --theme-toggle-icon: var(--text-secondary); - - /* 警告/高亮颜色 */ - --warning-bg: #fef3c7; - --warning-bg-light: #fde68a; - --warning-bg-dark: #78350f; - --warning-border: #fbbf24; - --warning-text: #92400e; - --warning-text-dark: #d97706; - --warning-text-darker: #b45309; - --warning-bg-alt: #fffbeb; - - /* 成功/健康颜色 */ - --success-bg: #d1fae5; - --success-bg-light: #ecfdf5; - --success-bg-alt: #f0fdf4; - --success-text: #065f46; - --success-text-light: #6ee7b7; - - /* 错误/危险颜色 */ - --danger-bg: #fee2e2; - --danger-bg-light: #fef2f2; - --danger-bg-alt: #fff5f5; - --danger-bg-medium: #fed7d7; - --danger-border: #fca5a5; - --danger-border-light: #feb2b2; - --danger-border-dark: #fecaca; - --danger-text: #991b1b; - --danger-text-light: #7f1d1d; - --danger-text-dark: #742a2a; - --danger-icon: #e53e3e; - --danger-label: #c53030; - --danger-alt: #dc3545; - --danger-secondary: #fd7e14; - - /* 信息/蓝色颜色 */ - --info-bg: #dbeafe; - --info-bg-light: #e0f2fe; - --info-bg-lighter: #eff6ff; - --info-bg-alt: #f0f9ff; - --info-text: #1e40af; - --info-text-dark: #0369a1; - --info-text-darker: #075985; - --info-border: #0ea5e9; - --info-hover: #bae6fd; - --info-color: #3b82f6; - --info-color-dark: #2563eb; - - /* 中性灰色 */ - --neutral-100: #f8f9fa; - --neutral-200: #e9ecef; - --neutral-300: #dee2e6; - --neutral-400: #adb5bd; - --neutral-500: #6c757d; - --neutral-600: #495057; - --neutral-700: #2c3e50; - --neutral-800: #8b95a5; - --neutral-alt: #f1f3f4; - - /* 日志颜色 */ - --log-time: #858585; - --log-info: #4ec9b0; - --log-error: #f48771; - --log-warn: #dcdcaa; - - /* 按钮颜色 */ - --btn-success: #28a745; - --btn-success-secondary: #20c997; - --btn-primary-hover: #047857; -} - -/* 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); - - /* 辅助色透明度版本 - 暗色 */ - --primary-10: rgba(16, 185, 129, 0.1); - --primary-30: rgba(16, 185, 129, 0.3); - --primary-40: rgba(16, 185, 129, 0.4); - - --success-10: rgba(16, 185, 129, 0.1); - - --warning-15: rgba(251, 191, 36, 0.15); - --warning-20: rgba(251, 191, 36, 0.2); - --warning-25: rgba(251, 191, 36, 0.25); - --warning-30: rgba(251, 191, 36, 0.3); - --warning-40: rgba(251, 191, 36, 0.4); - --warning-50: rgba(251, 191, 36, 0.5); - - --danger-30: rgba(248, 113, 113, 0.3); - --danger-40: rgba(248, 113, 113, 0.4); - --danger-70: rgba(248, 113, 113, 0.7); - - /* 基础颜色 */ - --white: #ffffff; - --black: #000000; - - /* 遮罩背景 */ - --overlay-bg: rgba(0, 0, 0, 0.8); - - /* 代码块和日志区域 */ - --code-bg: #0d1117; - --code-text: #e6edf3; - - /* 主题切换按钮 */ - --theme-toggle-bg: var(--bg-tertiary); - --theme-toggle-icon: #fbbf24; - - /* 警告/高亮颜色 */ - --warning-bg: #78350f; - --warning-bg-light: #92400e; - --warning-bg-dark: #78350f; - --warning-border: #b45309; - --warning-text: #fef3c7; - --warning-text-dark: #fde68a; - --warning-text-darker: #fde68a; - --warning-bg-alt: #78350f; - - /* 成功/健康颜色 */ - --success-bg: #064e3b; - --success-bg-light: #065f46; - --success-bg-alt: #064e3b; - --success-text: #6ee7b7; - --success-text-light: #6ee7b7; - - /* 错误/危险颜色 */ - --danger-bg: #7f1d1d; - --danger-bg-light: #7f1d1d; - --danger-bg-alt: #7f1d1d; - --danger-bg-medium: #991b1b; - --danger-border: #dc2626; - --danger-border-light: #dc2626; - --danger-border-dark: #dc2626; - --danger-text: #fca5a5; - --danger-text-light: #fecaca; - --danger-text-dark: #fecaca; - --danger-icon: #fca5a5; - --danger-label: #fca5a5; - --danger-alt: #dc2626; - --danger-secondary: #dc2626; - - /* 信息/蓝色颜色 */ - --info-bg: #1e3a5f; - --info-bg-light: #1e3a5f; - --info-bg-lighter: #1e3a5f; - --info-bg-alt: #1e3a5f; - --info-text: #93c5fd; - --info-text-dark: #93c5fd; - --info-text-darker: #93c5fd; - --info-border: #3b82f6; - --info-hover: #1e3a5f; - --info-color: #3b82f6; - --info-color-dark: #3b82f6; - - /* 中性灰色 */ - --neutral-100: var(--bg-secondary); - --neutral-200: var(--border-color); - --neutral-300: var(--border-color); - --neutral-400: var(--text-secondary); - --neutral-500: var(--text-secondary); - --neutral-600: var(--text-primary); - --neutral-700: var(--text-primary); - --neutral-800: var(--text-secondary); - --neutral-alt: var(--bg-tertiary); - - /* 日志颜色 */ - --log-time: #858585; - --log-info: #4ec9b0; - --log-error: #f48771; - --log-warn: #dcdcaa; - - /* 按钮颜色 */ - --btn-success: #28a745; - --btn-success-secondary: #20c997; - --btn-primary-hover: #047857; -} - -/* 基础样式 */ -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} - -body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; - background: var(--bg-secondary); - color: var(--text-primary); - line-height: 1.6; -} - -/* 容器 */ -.container { - display: flex; - flex-direction: column; - min-height: 100vh; -} - -/* Header */ -.header { - background: var(--bg-primary); - border-bottom: 1px solid var(--border-color); - box-shadow: var(--shadow-sm); - position: sticky; - top: 0; - z-index: 100; -} - -.header-content { - max-width: 1400px; - margin: 0 auto; - padding: 1rem 2rem; - display: flex; - justify-content: space-between; - align-items: center; -} - -.header h1 { - font-size: 1.5rem; - font-weight: 600; - color: var(--primary-color); -} - -.header h1 i { - margin-right: 0.5rem; -} - -.header-controls { - display: flex; - gap: 1rem; - align-items: center; -} - -.status-badge { - display: inline-flex; - align-items: center; - gap: 0.5rem; - padding: 0.5rem 1rem; - background: var(--bg-tertiary); - border-radius: 9999px; - font-size: 0.875rem; - font-weight: 500; -} - -.status-badge i { - color: var(--success-color); - animation: pulse 2s infinite; -} - -.status-badge.error i { - color: var(--danger-color); -} -.logout-btn { - padding: 8px 16px; - background: transparent; - color: var(--text-secondary); - border: 1px solid var(--border-color); - border-radius: 6px; - cursor: pointer; - font-size: 14px; - font-weight: 500; - transition: all 0.3s ease; - display: inline-flex; - align-items: center; - gap: 6px; -} - -.logout-btn:hover { - transform: translateY(-2px); - box-shadow: 0 4px 12px var(--primary-40); -} - -.logout-btn:active { - transform: translateY(0); -} - -.logout-btn i { - font-size: 14px; -} - -.github-link { - display: inline-flex; - align-items: center; - justify-content: center; - width: 40px; - height: 40px; - padding: 0; - background: transparent; - color: var(--text-secondary); - border: 1px solid var(--border-color); - border-radius: 50%; - cursor: pointer; - font-size: 1.125rem; - transition: var(--transition); - text-decoration: none; - position: relative; - overflow: hidden; -} - -.github-link:hover { - background: var(--bg-tertiary); - color: var(--primary-color); - border-color: var(--primary-color); - transform: translateY(-2px); - box-shadow: var(--shadow-md); -} - -.github-link:active { - transform: translateY(0); -} - -.github-link i { - transition: transform 0.3s ease; -} - -/* KIRO 购买链接 */ -.kiro-buy-link { - display: inline-flex; - align-items: center; - gap: 0.5rem; - padding: 0.5rem 1rem; - background: linear-gradient(135deg, var(--warning-color) 0%, #f97316 100%); - color: var(--white); - text-decoration: none; - border-radius: 9999px; - font-size: 0.875rem; - font-weight: 600; - transition: var(--transition); - box-shadow: 0 2px 8px var(--warning-30); -} - -.kiro-buy-link:hover { - transform: translateY(-2px); - box-shadow: 0 4px 12px var(--warning-40); - background: linear-gradient(135deg, #f97316 0%, var(--warning-color) 100%); -} - -.kiro-buy-link:active { - transform: translateY(0); -} - -.kiro-buy-link i { - font-size: 0.875rem; -} - -/* 高亮说明样式 */ -.highlight-note { - display: inline-flex; - align-items: center; - gap: 0.5rem; - padding: 1rem; - background: linear-gradient(135deg, var(--warning-bg) 0%, var(--warning-bg-light) 100%); - border: 1px solid var(--warning-border); - border-radius: 0.5rem; - margin-bottom: 1.5rem; - color: var(--warning-text); - font-weight: 500; - width: 100%; - box-sizing: border-box; -} - -.highlight-note i { - color: var(--warning-color); - font-size: 1.25rem; - flex-shrink: 0; -} - -.highlight-note span { - flex: 1; - text-align: center; -} - -@keyframes pulse { - 0%, 100% { - opacity: 1; - } - 50% { - opacity: 0.5; - } -} - -/* 主要内容区域 */ -.main-content { - display: flex; - flex: 1; - max-width: 1400px; - width: 100%; - margin: 0 auto; -} - -/* 侧边栏 */ -.sidebar { - width: 240px; - background: var(--bg-primary); - border-right: 1px solid var(--border-color); - padding: 1.5rem 0; -} - -.sidebar-nav { - display: flex; - flex-direction: column; - gap: 0.25rem; -} - -.nav-item { - display: flex; - align-items: center; - gap: 0.75rem; - padding: 0.75rem 1.5rem; - color: var(--text-secondary); - text-decoration: none; - transition: var(--transition); - font-weight: 500; - cursor: pointer; - user-select: none; - -webkit-user-select: none; - -webkit-tap-highlight-color: transparent; -} - -.nav-item:hover { - background: var(--bg-secondary); - color: var(--primary-color); -} - -.nav-item.active { - background: var(--bg-tertiary); - color: var(--primary-color); - border-right: 3px solid var(--primary-color); -} - -.nav-item i { - width: 20px; - text-align: center; -} - -/* 内容区域 */ -.content { - flex: 1; - padding: 2rem; - overflow-y: auto; -} - -.section { - display: none; -} - -.section.active { - display: block; -} - -.section h2 { - font-size: 1.875rem; - font-weight: 600; - margin-bottom: 1.5rem; - color: var(--text-primary); -} - -/* 统计卡片 */ -.stats-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); - gap: 1.5rem; - margin-bottom: 2rem; -} - -.stat-card { - background: var(--bg-primary); - padding: 1.5rem; - border-radius: 0.5rem; - box-shadow: var(--shadow-md); - display: flex; - align-items: center; - gap: 1rem; - transition: var(--transition); -} - -.stat-card:hover { - transform: translateY(-2px); - box-shadow: var(--shadow-lg); -} - -.stat-icon { - width: 60px; - height: 60px; - border-radius: 0.5rem; - display: flex; - align-items: center; - justify-content: center; - font-size: 1.5rem; - color: var(--primary-color); - background: var(--bg-tertiary); -} - -.stat-info h3 { - font-size: 2rem; - font-weight: 700; - margin-bottom: 0.25rem; -} - -.stat-info p { - color: var(--text-secondary); - font-size: 0.875rem; -} - -/* 表单样式 */ -.config-panel { - background: var(--bg-primary); - padding: 2rem; - border-radius: 0.5rem; - box-shadow: var(--shadow-md); -} - -.config-form { - max-width: 800px; - margin: 0 auto; -} - -.form-group { - margin-bottom: 1.5rem; -} - -.form-row { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 1rem; -} - -.form-group label { - display: block; - margin-bottom: 0.5rem; - font-weight: 500; - color: var(--text-primary); -} - -.optional-tag, .form-group label .optional-mark { - font-size: 0.75rem; - color: var(--text-secondary); - font-weight: 400; - margin-left: 0.25rem; - opacity: 0.8; -} - -.form-control { - width: 100%; - padding: 0.75rem; - border: 1px solid var(--border-color); - border-radius: 0.375rem; - font-size: 0.875rem; - transition: var(--transition); - background: var(--bg-primary); -} - -.form-control:focus { - outline: none; - border-color: var(--primary-color); - box-shadow: 0 0 0 3px var(--primary-10); -} - -textarea.form-control { - resize: vertical; - font-family: inherit; -} - -/* 密码输入框样式 */ -.password-input-group { - position: relative; -} - -.password-input-wrapper { - position: relative; - display: flex; - align-items: center; -} - -.password-input-wrapper .form-control { - padding-right: 3rem; -} - -.password-input-wrapper input[type="password"], -.password-input-wrapper input[type="text"] { - flex: 1; - padding-right: 3rem; -} - -.password-toggle { - position: absolute; - right: 0.75rem; - background: none; - border: none; - cursor: pointer; - padding: 0.25rem; - color: var(--text-secondary); - transition: var(--transition); - z-index: 1; - width: auto; - flex-shrink: 0; -} - -.password-toggle:hover { - color: var(--primary-color); -} - -.password-toggle i { - font-size: 1rem; - width: 1rem; - text-align: center; -} - -/* 模态框中的密码输入框样式 */ -.config-item .password-input-wrapper { - position: relative; - width: 100%; -} - -.config-item .password-input-wrapper input { - width: 100%; - padding-right: 2.5rem; - box-sizing: border-box; -} - -.config-item .password-toggle { - position: absolute; - right: 0.5rem; - padding: 0.25rem; - background: none; - border: none; - cursor: pointer; - color: var(--text-secondary); - transition: var(--transition); - width: auto; - height: auto; - line-height: 1; -} - -.config-item .password-toggle:hover { - color: var(--primary-color); -} - -/* 文件上传输入框样式 */ -.file-input-group { - position: relative; - display: flex; - align-items: center; - gap: 0.5rem; -} - -.file-input-group .form-control { - flex: 1; - padding-right: 0.75rem; -} - -.file-input-group .btn-outline { - height: 38px; - width: 38px; - padding: 0; - display: flex; - align-items: center; - justify-content: center; - border-radius: 6px; - flex-shrink: 0; - background: var(--bg-tertiary); - color: var(--text-secondary); - border: 1px solid var(--border-color); -} - -.file-input-group .btn-outline:hover { - background: var(--bg-secondary); - color: var(--primary-color); - border-color: var(--primary-color); -} - -/* 兼容旧的 class 名 */ -.upload-btn { - /* 移除之前的 position: absolute 等样式,改为 inline-flex 配合父级 flex 布局 */ - display: inline-flex; -} - -.upload-btn:hover { - background: var(--bg-tertiary); - color: var(--primary-color); - border-color: var(--primary-color); -} - -.upload-btn i { - font-size: 0.875rem; -} - -/* 单选按钮组 */ -.radio-group { - display: flex; - gap: 1.5rem; - margin-top: 0.5rem; -} - -.radio-label { - display: flex; - align-items: center; - gap: 0.5rem; - cursor: pointer; - font-weight: 500; - color: var(--text-primary); -} - -.radio-label input[type="radio"] { - margin: 0; - cursor: pointer; -} - -/* 提供商配置组 */ -.provider-config { - border: 1px solid var(--border-color); - border-radius: 0.5rem; - padding: 1.5rem; - margin-top: 1rem; - background: var(--bg-secondary); -} - -/* 高级配置区域 */ -.advanced-config-section { - border: 1px solid var(--border-color); - border-radius: 0.5rem; - padding: 1.5rem; - margin-top: 1.5rem; - background: var(--bg-secondary); -} - -.advanced-config-section h3 { - color: var(--text-primary); - font-size: 1.25rem; - font-weight: 600; - margin-bottom: 1.5rem; - padding-bottom: 0.5rem; - border-bottom: 1px solid var(--border-color); - display: flex; - align-items: center; - gap: 0.5rem; -} - -.advanced-config-section h3 i { - color: var(--primary-color); -} - -/* 代理配置区域 */ -.proxy-config-section { - border: 1px solid var(--border-color); - border-radius: 0.5rem; - padding: 1rem 1.5rem; - margin-bottom: 1.5rem; - background: var(--bg-primary); -} - -.proxy-config-section h4 { - color: var(--text-primary); - font-size: 1rem; - font-weight: 600; - margin-bottom: 1rem; - display: flex; - align-items: center; - gap: 0.5rem; -} - -.proxy-config-section h4 i { - color: var(--primary-color); -} - -/* 复选框组样式 */ -.checkbox-group { - display: flex; - flex-wrap: wrap; - gap: 1rem; -} - -.checkbox-label { - display: flex; - align-items: center; - gap: 0.5rem; - cursor: pointer; - padding: 0.5rem 0.75rem; - border: 1px solid var(--border-color); - border-radius: 0.375rem; - background: var(--bg-secondary); - transition: all 0.2s ease; -} - -.checkbox-label:hover { - border-color: var(--primary-color); - background: var(--bg-hover); -} - -.checkbox-label input[type="checkbox"] { - width: 1rem; - height: 1rem; - accent-color: var(--primary-color); - cursor: pointer; -} - -.checkbox-label input[type="checkbox"]:checked + span { - color: var(--primary-color); - font-weight: 500; -} - -.config-row { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 1rem; - margin-bottom: 1.5rem; -} - -.config-row:last-child { - margin-bottom: 0; -} - -/* 复选框样式 */ -.form-group input[type="checkbox"] { - width: 1rem; - height: 1rem; - accent-color: var(--primary-color); - 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 { - grid-template-columns: 1fr; - gap: 1.5rem; - } -} - -.form-actions { - display: flex; - gap: 1rem; - margin-top: 2rem; - justify-content: flex-end; -} - -/* 按钮 */ -.btn { - display: inline-flex; - align-items: center; - gap: 0.5rem; - padding: 0.75rem 1.5rem; - border: none; - border-radius: 0.375rem; - font-size: 0.875rem; - font-weight: 500; - cursor: pointer; - transition: var(--transition); - text-decoration: none; -} - -.btn:hover { - transform: translateY(-1px); -} - -.btn-primary { - padding: 8px 16px; - background: transparent; - color: var(--text-secondary); - border: 1px solid var(--border-color); - border-radius: 6px; - cursor: pointer; - font-size: 14px; - font-weight: 500; - transition: all 0.3s ease; - display: inline-flex; - align-items: center; - gap: 6px; -} - -.btn-primary:hover { - transform: translateY(-2px); - background: var(--bg-tertiary); - color: var(--primary-color); - border-color: var(--primary-color); -} - -.btn-primary:active { - transform: translateY(0); -} - -.btn-primary i { - font-size: 14px; -} - -.btn-success { - background: var(--success-color); - color: white; -} - -.btn-success:hover { - background: var(--primary-color); -} - -.btn-secondary { - background: var(--bg-tertiary); - color: var(--text-primary); -} - -.btn-secondary:hover { - background: var(--border-color); -} - -.btn-danger { - background: var(--danger-color); - color: white; -} - -.btn-danger:hover { - background: var(--danger-color); -} - -/* 提供商列表 */ -.providers-container { - background: var(--bg-primary); - padding: 1.5rem; - border-radius: 0.5rem; - box-shadow: var(--shadow-md); -} - -.providers-list { - display: flex; - flex-direction: column; - gap: 1rem; -} - -.provider-item { - border: 1px solid var(--border-color); - border-radius: 0.5rem; - padding: 1.5rem; - transition: var(--transition); -} - -.provider-item:hover { - border-color: var(--primary-color); - box-shadow: var(--shadow-sm); -} - -.provider-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 1rem; -} - -.provider-name { - font-size: 1.125rem; - font-weight: 600; - color: var(--text-primary); -} - -.provider-header-right { - display: flex; - align-items: center; - gap: 0.75rem; -} - -.provider-status { - display: inline-flex; - align-items: center; - gap: 0.5rem; - padding: 0.25rem 0.75rem; - border-radius: 9999px; - font-size: 0.75rem; - font-weight: 500; -} - -/* Path Routing Examples Panel */ -.routing-examples-panel { - background: var(--bg-primary); - padding: 1.5rem; - border-radius: 0.5rem; - box-shadow: var(--shadow-md); - margin-top: 1.5rem; - margin-bottom: 1.5rem; -} - -.routing-examples-panel h3 { - font-size: 1.25rem; - font-weight: 600; - margin-bottom: 0.5rem; - color: var(--text-primary); - display: flex; - align-items: center; - gap: 0.5rem; -} - -.routing-examples-panel h3 i { - color: var(--primary-color); -} - -.routing-description { - color: var(--text-secondary); - font-size: 0.875rem; - margin-bottom: 1.5rem; - line-height: 1.5; -} - -.routing-examples-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); - gap: 1.5rem; - margin-bottom: 2rem; -} - -.routing-example-card { - border: 1px solid var(--border-color); - border-radius: 0.5rem; - overflow: hidden; - transition: var(--transition); - background: var(--bg-secondary); -} - -.routing-example-card:hover { - border-color: var(--primary-color); - box-shadow: var(--shadow-md); - transform: translateY(-2px); -} - -.routing-card-header { - background: var(--bg-primary); - padding: 1rem 1.5rem; - display: flex; - align-items: center; - gap: 0.75rem; - border-bottom: 1px solid var(--border-color); -} - -.routing-card-header i { - font-size: 1.25rem; - color: var(--primary-color); -} - -.routing-card-header h4 { - font-size: 1rem; - font-weight: 600; - color: var(--text-primary); - margin: 0; - flex: 1; -} - -.provider-badge { - padding: 0.25rem 0.5rem; - border-radius: 9999px; - font-size: 0.75rem; - font-weight: 500; - text-transform: uppercase; - letter-spacing: 0.05em; -} - -.provider-badge.official { - background: var(--info-bg); - color: var(--info-text); -} - -.provider-badge.oauth { - background: var(--success-bg); - color: var(--success-text); -} - -.provider-badge.responses { - background: var(--warning-bg); - color: var(--warning-text); -} - -.routing-card-content { - padding: 1.5rem; -} - -/* 协议标签样式 */ -.protocol-tabs { - display: flex; - margin-bottom: 1rem; - border-bottom: 1px solid var(--border-color); -} - -.protocol-tab { - background: none; - border: none; - padding: 0.75rem 1rem; - cursor: pointer; - font-size: 0.875rem; - font-weight: 500; - color: var(--text-secondary); - border-bottom: 2px solid transparent; - transition: var(--transition); - position: relative; -} - -.protocol-tab:hover { - color: var(--primary-color); - background: var(--bg-tertiary); -} - -.protocol-tab.active { - color: var(--primary-color); - border-bottom-color: var(--primary-color); - background: var(--bg-secondary); -} - -/* 协议内容区域 */ -.protocol-content { - display: none; - animation: fadeIn 0.3s ease; -} - -.protocol-content.active { - display: block; -} - -@keyframes fadeIn { - from { opacity: 0; transform: translateY(10px); } - to { opacity: 1; transform: translateY(0); } -} - -.endpoint-info { - margin-bottom: 1rem; -} - -.endpoint-info label { - display: block; - font-size: 0.875rem; - font-weight: 500; - color: var(--text-secondary); - margin-bottom: 0.5rem; -} - -.endpoint-path { - display: inline-block; - background: var(--bg-primary); - padding: 0.5rem 0.75rem; - border-radius: 0.375rem; - font-family: 'Courier New', monospace; - font-size: 0.875rem; - color: var(--text-primary); - border: 1px solid var(--border-color); - position: relative; - padding-right: 2.5rem; -} - -.copy-btn { - position: absolute; - right: 0.5rem; - top: 50%; - transform: translateY(-50%); - background: none; - border: none; - color: var(--text-secondary); - cursor: pointer; - padding: 0.25rem; - border-radius: 0.25rem; - transition: var(--transition); -} - -.copy-btn:hover { - background: var(--bg-tertiary); - color: var(--primary-color); -} - -.usage-example { - margin-top: 1rem; -} - -.usage-example label { - display: block; - font-size: 0.875rem; - font-weight: 500; - color: var(--text-secondary); - margin-bottom: 0.5rem; -} - -.usage-example pre { - background: var(--code-bg); - color: var(--code-text); - padding: 1rem; - border-radius: 0.375rem; - overflow-x: auto; - font-size: 0.75rem; - line-height: 1.4; - margin: 0; -} - -.usage-example code { - font-family: 'Courier New', monospace; - white-space: pre-wrap; -} - -.routing-tips { - background: var(--bg-secondary); - padding: 1.5rem; - border-radius: 0.5rem; - border-left: 4px solid var(--primary-color); -} - -.routing-tips h4 { - font-size: 1rem; - font-weight: 600; - color: var(--text-primary); - margin-bottom: 1rem; - display: flex; - align-items: center; - gap: 0.5rem; -} - -.routing-tips h4 i { - color: var(--warning-color); -} - -.routing-tips ul { - margin: 0; - padding-left: 1.5rem; -} - -.routing-tips li { - margin-bottom: 0.75rem; - color: var(--text-secondary); - font-size: 0.875rem; - line-height: 1.5; -} - -.routing-tips code { - background: var(--bg-primary); - padding: 0.125rem 0.25rem; - border-radius: 0.25rem; - font-family: 'Courier New', monospace; - font-size: 0.75rem; - color: var(--primary-color); -} - -/* System Info Panel in Dashboard */ -.system-info-panel { - background: var(--bg-primary); - padding: 1.5rem; - border-radius: 0.5rem; - box-shadow: var(--shadow-md); - margin-top: 1.5rem; -} - -.system-info-panel h3 { - font-size: 1.25rem; - font-weight: 600; - color: var(--text-primary); - margin: 0; -} - -.system-info-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 1.5rem; -} - -.update-controls { - display: flex; - gap: 0.75rem; - align-items: center; -} - -.btn-sm { - padding: 4px 10px; - font-size: 12px; -} - -.update-badge { - display: inline-flex; - align-items: center; - gap: 4px; - background: var(--warning-bg); - color: var(--warning-text); - padding: 2px 8px; - border-radius: 9999px; - font-size: 11px; - font-weight: 600; - margin-left: 8px; - border: 1px solid var(--warning-bg-light); - animation: bounce-in 0.5s ease-out; -} - -@keyframes bounce-in { - 0% { transform: scale(0.8); opacity: 0; } - 70% { transform: scale(1.1); } - 100% { transform: scale(1); opacity: 1; } -} - -.info-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 1rem; -} - -.info-item { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.info-item .info-label { - display: flex; - align-items: center; - gap: 0.5rem; - color: var(--text-secondary); - font-size: 0.875rem; - font-weight: 500; -} - -.info-item .info-label i { - color: var(--primary-color); - width: 16px; - text-align: center; -} - -.info-item .info-value { - color: var(--text-primary); - font-size: 1rem; - font-weight: 600; -} - -.version-display-wrapper { - display: flex; - align-items: center; - padding-left: 1.5rem; - flex-wrap: wrap; - gap: 0.5rem; -} - -.status-healthy { - background: var(--success-bg); - color: var(--success-text); -} - -.status-unhealthy { - background: var(--danger-bg); - color: var(--danger-text); -} - -.provider-stats { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); - gap: 1rem; - margin-top: 1rem; -} - -.provider-stat { - display: flex; - flex-direction: column; -} - -.provider-stat-label { - font-size: 0.75rem; - color: var(--text-secondary); - margin-bottom: 0.25rem; -} - -.provider-stat-value { - font-size: 1.125rem; - font-weight: 600; - color: var(--text-primary); -} - -/* 日志 */ -.logs-controls { - display: flex; - gap: 1rem; - margin-bottom: 1rem; -} - -.logs-container { - background: var(--code-bg); - color: var(--code-text); - padding: 1.5rem; - border-radius: 0.5rem; - height: 800px; - overflow-y: auto; - font-family: 'Courier New', monospace; - font-size: 0.875rem; - box-shadow: var(--shadow-md); -} - -.log-entry { - margin-bottom: 0.5rem; - padding: 0.25rem 0; -} - -.log-time { - color: var(--log-time); -} - -.log-level-info { - color: var(--log-info); -} - -.log-level-error { - color: var(--log-error); -} - -.log-level-warn { - color: var(--log-warn); -} - -/* 系统信息 */ -.system-info { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); - gap: 1.5rem; -} - -.info-card { - background: var(--bg-primary); - padding: 1.5rem; - border-radius: 0.5rem; - box-shadow: var(--shadow-md); -} - -.info-card h3 { - font-size: 1.125rem; - font-weight: 600; - margin-bottom: 1rem; - color: var(--text-primary); -} - -.info-row { - display: flex; - justify-content: space-between; - align-items: center; - padding: 0.5rem 0; - border-bottom: 1px solid var(--border-color); -} - -.info-row:last-child { - border-bottom: none; -} - -.info-label { - color: var(--text-secondary); - font-size: 0.875rem; -} - -.info-value { - color: var(--text-primary); - font-weight: 500; - font-size: 0.875rem; -} - -/* 通知 */ -.toast-container { - position: fixed; - top: 1rem; - right: 1rem; - z-index: 1001; - display: flex; - flex-direction: column; - gap: 0.5rem; - pointer-events: none; -} - -.toast { - background: var(--bg-primary); - border: 1px solid var(--border-color); - border-radius: 0.5rem; - padding: 1rem 1.5rem; - box-shadow: var(--shadow-lg); - min-width: 300px; - animation: slideIn 0.3s ease; - pointer-events: auto; -} - -.toast.success { - border-left: 4px solid var(--success-color); -} - -.toast.error { - border-left: 4px solid var(--danger-color); -} - -@keyframes slideIn { - from { - transform: translateX(100%); - opacity: 0; - } - to { - transform: translateX(0); - opacity: 1; - } -} - -/* OAuth刷新切换开关 */ -.oauth-refresh-toggle { - display: flex; - align-items: center; - gap: 0.75rem; - margin-top: 0.5rem; -} - -.toggle-switch { - position: relative; - display: inline-block; - width: 48px; - height: 24px; -} - -.toggle-switch input { - opacity: 0; - width: 0; - height: 0; -} - -.toggle-slider { - position: absolute; - cursor: pointer; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: var(--bg-tertiary); - border: 1px solid var(--border-color); - transition: var(--transition); - border-radius: 24px; -} - -.toggle-slider:before { - position: absolute; - content: ""; - height: 18px; - width: 18px; - left: 3px; - bottom: 2px; - background-color: white; - transition: var(--transition); - border-radius: 50%; - box-shadow: 0 1px 3px var(--neutral-shadow-30); -} - -input:checked + .toggle-slider { - background-color: var(--primary-color); - border-color: var(--primary-color); -} - -input:checked + .toggle-slider:before { - transform: translateX(24px); -} - -.toggle-label { - font-weight: 500; - color: var(--text-primary); - font-size: 0.875rem; -} - -/* 系统提示区域 */ -.system-prompt-section { - margin-top: 2rem; - padding-top: 1.5rem; - border-top: 1px solid var(--border-color); -} - -.pool-section .form-text { - margin-top: 0.5rem; - color: var(--text-secondary); - font-size: 0.75rem; - font-style: italic; -} - -/* 提供商类型显示 */ -.provider-type-text { - font-size: 16px; - font-weight: 600; - color: var(--secondary-color); - cursor: pointer; - padding: 4px 8px; - border-radius: 4px; - transition: all 0.3s ease; -} - -.provider-type-text:hover { - background: var(--bg-tertiary); - color: var(--primary-color); -} - -/* 模态框样式 */ -.provider-modal { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: var(--overlay-bg); - backdrop-filter: blur(4px); - display: flex; - justify-content: center; - align-items: center; - z-index: 1000; - animation: fadeIn 0.3s ease; -} - -@keyframes fadeIn { - from { opacity: 0; } - to { opacity: 1; } -} - -.provider-modal-content { - background: var(--bg-primary); - border-radius: 16px; - width: 95%; - max-width: 1200px; - max-height: 85vh; - overflow: hidden; - box-shadow: 0 20px 60px var(--neutral-shadow-30); - animation: slideIn 0.3s ease; -} - -@keyframes slideIn { - from { - transform: translateY(-50px); - opacity: 0; - } - to { - transform: translateY(0); - opacity: 1; - } -} - -.provider-modal-header { - padding: 24px; - border-bottom: 1px solid var(--neutral-200); - display: flex; - justify-content: space-between; - align-items: center; - background: linear-gradient(135deg, var(--neutral-100) 0%, var(--neutral-200) 100%); -} - -.provider-modal-header h3 { - margin: 0; - color: var(--neutral-700); - font-size: 20px; - font-weight: 600; -} - -.modal-close { - background: none; - border: none; - font-size: 20px; - cursor: pointer; - color: var(--neutral-500); - padding: 8px; - border-radius: 50%; - transition: all 0.3s ease; -} - -.modal-close:hover { - background: var(--neutral-200); - color: var(--neutral-600); - transform: rotate(90deg); -} - -.provider-modal-body { - padding: 24px; - max-height: calc(85vh - 80px); - overflow-y: auto; -} - -.provider-summary { - display: flex; - gap: 24px; - align-items: center; - margin-bottom: 24px; - padding: 20px; - background: linear-gradient(135deg, var(--neutral-100) 0%, var(--bg-primary) 100%); - border-radius: 12px; - border: 1px solid var(--neutral-200); -} - -.provider-summary-item { - display: flex; - flex-direction: column; - align-items: center; - padding: 12px; -} - -.provider-summary-item .label { - font-size: 13px; - color: var(--neutral-500); - margin-bottom: 8px; - font-weight: 500; -} - -.provider-summary-item .value { - font-size: 24px; - font-weight: bold; - color: var(--neutral-700); -} - -.provider-summary-actions { - margin-left: auto; - display: flex; - align-items: center; - gap: 0.75rem; -} - -.provider-actions { - margin-bottom: 24px; -} - -.provider-item-detail { - border: 1px solid var(--neutral-200); - border-radius: 12px; - margin-bottom: 16px; - overflow: hidden; - box-shadow: 0 2px 8px var(--neutral-shadow-md); - transition: all 0.3s ease; -} - -.provider-item-detail:hover { - box-shadow: 0 4px 16px var(--neutral-shadow-lg); - transform: translateY(-1px); -} - -.provider-item-header { - padding: 20px; - background: linear-gradient(135deg, var(--neutral-100) 0%, var(--bg-primary) 100%); - display: flex; - justify-content: space-between; - align-items: center; - cursor: pointer; - transition: all 0.3s ease; -} - -.provider-item-header:hover { - background: linear-gradient(135deg, var(--neutral-200) 0%, var(--neutral-100) 100%); -} - -.provider-info { - flex: 1; -} - -.provider-name { - font-weight: 600; - margin-bottom: 8px; - color: var(--neutral-700); - font-size: 15px; -} - -.provider-meta { - font-size: 13px; - color: var(--neutral-500); - line-height: 1.4; -} - -.provider-health-meta { - font-size: 12px; - color: var(--neutral-800); - margin-top: 4px; - line-height: 1.4; -} - -.provider-health-meta i { - margin-right: 4px; - opacity: 0.7; -} - -.provider-error-info { - margin-top: 8px; - padding: 8px 12px; - background: linear-gradient(135deg, var(--danger-bg-alt) 0%, var(--danger-bg-medium) 100%); - border: 1px solid var(--danger-border-light); - border-radius: 6px; - font-size: 12px; - display: flex; - align-items: flex-start; - gap: 8px; -} - -.provider-error-info i { - color: var(--danger-icon); - flex-shrink: 0; - margin-top: 2px; -} - -.provider-error-info .error-label { - color: var(--danger-label); - font-weight: 600; - white-space: nowrap; -} - -.provider-error-info .error-message { - color: var(--danger-text-dark); - word-break: break-word; - max-height: 60px; - overflow-y: auto; -} - -.provider-actions-group { - display: flex; - gap: 8px; - align-items: center; -} - -.btn-small { - padding: 8px 12px; - font-size: 12px; - border: none; - border-radius: 0.375rem; - cursor: pointer; - display: flex; - align-items: center; - gap: 6px; - font-weight: 500; - transition: all 0.3s ease; -} - -.btn-edit { - background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); - color: var(--white); - box-shadow: 0 2px 8px var(--primary-30); -} - -.btn-edit:hover { - transform: translateY(-1px); - box-shadow: 0 4px 12px var(--primary-40); -} - -.btn-delete { - background: linear-gradient(135deg, var(--danger-alt) 0%, var(--danger-secondary) 100%); - color: var(--white); - box-shadow: 0 2px 8px var(--danger-30); -} - -.btn-quick-link { - background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); - color: white; - border: none; - border-radius: 4px; - padding: 4px 8px; - font-size: 11px; - cursor: pointer; - margin-left: 8px; - display: inline-flex; - align-items: center; - gap: 4px; - transition: all 0.2s ease; - box-shadow: 0 2px 6px var(--indigo-30); -} - -.btn-quick-link:hover { - transform: translateY(-1px); - box-shadow: var(--shadow-md); - background: linear-gradient(135deg, var(--btn-primary-hover) 0%, var(--primary-color) 100%); -} - -.btn-quick-link i { - font-size: 10px; -} - -.btn-batch-link { - background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); - color: white; - border: none; - border-radius: 6px; - padding: 6px 12px; - font-size: 12px; - cursor: pointer; - margin-left: 12px; - display: inline-flex; - align-items: center; - gap: 6px; - transition: all 0.2s ease; - box-shadow: 0 2px 8px var(--indigo-30); - font-weight: 500; -} - -.btn-batch-link:hover { - transform: translateY(-1px); - box-shadow: var(--shadow-md); - background: linear-gradient(135deg, var(--btn-primary-hover) 0%, var(--primary-color) 100%); -} - -.btn-batch-link i { - font-size: 11px; -} - -.btn-delete:hover { - transform: translateY(-1px); - box-shadow: 0 4px 12px var(--danger-40); -} - -.btn-save { - background: linear-gradient(135deg, var(--btn-success) 0%, var(--btn-success-secondary) 100%); - color: var(--white); - box-shadow: 0 2px 8px var(--danger-30); -} - -.btn-save:hover { - transform: translateY(-1px); - box-shadow: 0 4px 12px var(--danger-40); -} - -.btn-cancel { - background: linear-gradient(135deg, var(--neutral-500) 0%, var(--neutral-600) 100%); - color: var(--white); - box-shadow: 0 2px 8px var(--primary-30); -} - -.btn-cancel:hover { - transform: translateY(-1px); - box-shadow: 0 4px 12px var(--primary-40); -} - -.provider-item-content { - padding: 20px; - display: none; - border-top: 1px solid var(--neutral-200); - background: var(--bg-primary); -} - -.provider-item-content.expanded { - display: block; -} - -.config-item { - display: flex; - flex-direction: column; -} - -.config-item label { - font-size: 13px; - color: var(--neutral-600); - margin-bottom: 8px; - font-weight: 500; -} - -.config-item input, .config-item textarea, .config-item select { - padding: 12px; - border: 2px solid var(--neutral-200); - border-radius: 8px; - font-size: 13px; - transition: all 0.3s ease; -} - -.config-item input:focus, .config-item textarea:focus, .config-item select:focus { - outline: none; - border-color: var(--primary-color); - box-shadow: 0 0 0 3px var(--primary-10); -} - -.config-item input[readonly], .config-item select[disabled] { - background: var(--neutral-100); - color: var(--neutral-500); -} - -/* 模态框中的文件上传输入框样式 */ -.config-item .file-input-group { - position: relative; - display: flex; - align-items: center; - gap: 0.5rem; -} - -.config-item .file-input-group input { - flex: 1; - padding-right: 0.75rem; - box-sizing: border-box; -} - -.add-provider-form { - background: linear-gradient(135deg, var(--neutral-100) 0%, var(--bg-primary) 100%); - padding: 24px; - border-radius: 12px; - margin-bottom: 24px; - border: 1px solid var(--neutral-200); -} - -.add-provider-form h4 { - margin: 0 0 20px 0; - color: var(--neutral-700); - font-size: 18px; - font-weight: 600; -} - -.form-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); - gap: 20px; - margin-bottom: 20px; -} - -.form-group { - display: flex; - flex-direction: column; -} - -.form-group label { - font-size: 13px; - color: var(--neutral-600); - margin-bottom: 8px; - font-weight: 500; -} - -.form-group input, .form-group select { - padding: 12px; - border: 2px solid var(--neutral-200); - border-radius: 8px; - font-size: 14px; - transition: all 0.3s ease; -} - -.form-group input:focus, .form-group select:focus { - outline: none; - border-color: var(--primary-color); - box-shadow: 0 0 0 3px var(--primary-10); -} - -/* 无提供商提示 */ -.no-providers { - text-align: center; - padding: 2rem; - color: var(--text-secondary); -} - -.no-providers p { - margin: 0; - font-size: 1rem; -} - -/* 无配置文件提示 */ -.no-configs { - text-align: center; - padding: 2rem; - color: var(--text-secondary); -} - -.no-configs p { - margin: 0; - font-size: 1rem; -} - -/* 响应式 */ -@media (max-width: 768px) { - .main-content { - flex-direction: column; - } - - .sidebar { - width: 100%; - border-right: none; - border-bottom: 1px solid var(--border-color); - } - - .sidebar-nav { - flex-direction: row; - overflow-x: auto; - padding: 0 1rem; - } - - .form-row { - grid-template-columns: 1fr; - } - - .stats-grid { - grid-template-columns: 1fr; - } - - .oauth-refresh-toggle { - flex-direction: column; - align-items: flex-start; - gap: 0.5rem; - } - - .provider-modal-content { - width: 98%; - max-height: 95vh; - } - - .provider-summary { - flex-direction: column; - align-items: flex-start; - gap: 16px; - } - - .provider-summary-actions { - margin-left: 0; - } - - .form-grid { - grid-template-columns: 1fr; - } -} - -/* 健康状态高亮样式 */ -.provider-item-detail.unhealthy { - border: 2px solid var(--warning-color); - background: linear-gradient(135deg, var(--warning-bg) 0%, var(--bg-primary) 100%); - box-shadow: 0 4px 12px var(--warning-15); - animation: pulseWarning 2s infinite; -} - -.provider-item-detail.unhealthy:hover { - border-color: var(--warning-color); - box-shadow: 0 6px 20px var(--warning-25); - transform: translateY(-2px); -} - -.provider-item-detail.unhealthy .provider-item-header { - background: linear-gradient(135deg, var(--warning-bg) 0%, var(--bg-primary) 100%); - border-bottom: 1px solid var(--warning-20); -} - -.provider-item-detail.healthy { - border: 1px solid var(--neutral-200); - background: linear-gradient(135deg, var(--neutral-100) 0%, var(--bg-primary) 100%); -} - -.provider-item-detail.healthy:hover { - border-color: var(--primary-color); - box-shadow: 0 4px 16px var(--neutral-shadow-lg); -} - -.provider-item-detail.healthy .provider-item-header { - background: linear-gradient(135deg, var(--neutral-100) 0%, var(--bg-primary) 100%); -} - -.health-status { - display: inline-flex; - align-items: center; - gap: 0.5rem; - font-weight: 500; - padding: 0.25rem 0.5rem; - border-radius: 6px; - transition: var(--transition); -} - -.provider-item-detail.unhealthy .health-status { - color: var(--warning-text); - background: var(--warning-15); -} - -.provider-item-detail.healthy .health-status { - color: var(--success-text); - background: var(--success-10); -} - -.text-success { - color: var(--success-color) !important; -} - -.text-warning { - color: var(--warning-color) !important; -} - -.text-danger { - color: var(--danger-color) !important; -} - -@keyframes pulseWarning { - 0%, 100% { - box-shadow: 0 4px 12px var(--warning-15); - } - 50% { - box-shadow: 0 4px 12px var(--warning-30); - } -} - -/* 异常状态的特殊标识 */ -.provider-item-detail.unhealthy::before { - content: ''; - position: absolute; - left: 0; - top: 0; - bottom: 0; - width: 4px; - background: linear-gradient(135deg, var(--warning-color) 0%, var(--warning-color) 100%); - border-radius: 0 2px 2px 0; - z-index: 1; -} - -.provider-item-detail.unhealthy { - position: relative; -} - -/* 配置管理页面样式 */ -.upload-config-panel { - background: var(--bg-primary); - padding: 2rem; - border-radius: 0.5rem; - box-shadow: var(--shadow-md); -} - -.config-search-panel { - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: 0.5rem; - padding: 1.5rem; - margin-bottom: 2rem; -} - -.search-controls { - display: grid; - grid-template-columns: 2fr 1fr auto; - gap: 1rem; - align-items: end; -} - -.search-input-group { - position: relative; - display: flex; - align-items: center; -} - -.search-input-group .form-control { - flex: 1; - padding-right: 3rem; -} - -.search-input-group .btn { - position: absolute; - right: 0.5rem; - padding: 0.5rem 0.75rem; - background: var(--primary-color); - color: white; - border: none; - border-radius: 0.375rem; - cursor: pointer; - transition: var(--transition); -} - -.search-input-group .btn:hover { - background: var(--btn-primary-hover); - transform: translateY(-1px); -} - -.config-list-container { - background: var(--bg-primary); - border: 1px solid var(--border-color); - border-radius: 0.5rem; - overflow: hidden; -} - -.config-list-header { - background: var(--bg-tertiary); - padding: 1rem 1.5rem; - border-bottom: 1px solid var(--border-color); - display: flex; - justify-content: space-between; - align-items: center; -} - -.config-list-header h3 { - margin: 0; - font-size: 1.125rem; - font-weight: 600; - color: var(--text-primary); -} - -.config-stats { - display: flex; - gap: 1rem; - align-items: center; -} - -.config-stats span { - font-size: 0.875rem; - font-weight: 500; - padding: 0.25rem 0.5rem; - border-radius: 0.25rem; -} - -.status-used { - background: var(--success-bg); - color: var(--success-text); -} - -.status-unused { - background: var(--warning-bg); - color: var(--warning-text); -} - -.status-invalid { - background: var(--danger-bg); - color: var(--danger-text); -} - -.config-list { - overflow-y: auto; -} - -.config-item-manager { - padding: 1.5rem; - border-bottom: 1px solid var(--border-color); - transition: var(--transition); - cursor: pointer; -} - -.config-item-manager:hover { - background: var(--bg-secondary); -} - -.config-item-manager:last-child { - border-bottom: none; -} - -.config-item-header { - display: flex; - justify-content: between; - align-items: center; - margin-bottom: 0.75rem; -} - -.config-item-name { - font-size: 1rem; - font-weight: 600; - color: var(--text-primary); - flex: 1; -} - -.config-item-path { - font-size: 0.75rem; - color: var(--text-secondary); - font-family: 'Courier New', monospace; - background: var(--bg-tertiary); - padding: 0.25rem 0.5rem; - border-radius: 0.25rem; - margin: 0 0.5rem; - max-width: 300px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.config-item-meta { - display: flex; - gap: 1rem; - align-items: center; - font-size: 0.875rem; - color: var(--text-secondary); -} - -.config-item-type { - display: inline-flex; - align-items: center; - gap: 0.25rem; - padding: 0.25rem 0.5rem; - background: var(--bg-tertiary); - border-radius: 0.25rem; - font-size: 0.75rem; - font-weight: 500; -} - -.config-item-size { - font-family: 'Courier New', monospace; -} - -.config-item-modified { - font-family: 'Courier New', monospace; -} - -.config-item-status { - display: inline-flex; - align-items: center; - gap: 0.25rem; - padding: 0.25rem 0.5rem; - border-radius: 0.25rem; - font-size: 0.75rem; - font-weight: 500; -} - -.config-item-manager.used .config-item-status { - background: var(--success-bg); - color: var(--success-text); -} - -.config-item-manager.unused .config-item-status { - background: var(--warning-bg); - color: var(--warning-text); -} - -.config-item-manager.invalid .config-item-status { - background: var(--danger-bg); - color: var(--danger-text); -} - -.config-item-details { - margin-top: 0.75rem; - padding-top: 0.75rem; - border-top: 1px solid var(--border-color); - display: none; -} - -.config-item-manager.expanded .config-item-details { - display: block; -} - -.config-details-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 1rem; - margin-bottom: 1rem; -} - -.config-detail-item { - display: flex; - flex-direction: column; - gap: 0.25rem; -} - -.config-detail-label { - font-size: 0.75rem; - color: var(--text-secondary); - font-weight: 500; - text-transform: uppercase; - letter-spacing: 0.05em; -} - -.config-detail-value { - font-size: 0.875rem; - color: var(--text-primary); - font-family: 'Courier New', monospace; - word-break: break-all; -} - -.config-item-actions { - display: flex; - gap: 0.5rem; - margin-top: 0.75rem; - padding-top: 0.75rem; - border-top: 1px solid var(--border-color); -} - -.btn-small { - padding: 0.5rem 0.75rem; - font-size: 0.75rem; - border: none; - border-radius: 0.25rem; - cursor: pointer; - transition: var(--transition); - display: inline-flex; - align-items: center; - gap: 0.25rem; -} - -.btn-view { - background: var(--primary-color); - color: white; -} - -.btn-view:hover { - background: var(--btn-primary-hover); -} - -.btn-delete-small { - background: var(--danger-color); - color: white; -} - -.btn-delete-small:hover { - background: var(--danger-color); -} - -.config-item-manager.expanded { - background: var(--bg-secondary); -} - -.config-item-manager.expanded:hover { - background: var(--bg-primary); -} - -/* 响应式设计 */ -@media (max-width: 768px) { - .search-controls { - grid-template-columns: 1fr; - gap: 1rem; - } - - .config-list-header { - flex-direction: column; - align-items: flex-start; - gap: 0.5rem; - } - - .config-stats { - flex-wrap: wrap; - } - - .config-item-header { - flex-direction: column; - align-items: flex-start; - gap: 0.5rem; - } - - .config-item-path { - max-width: 100%; - margin: 0; - } - - .config-item-meta { - flex-wrap: wrap; - gap: 0.5rem; - } - - .config-details-grid { - grid-template-columns: 1fr; - } - - .config-item-actions { - flex-wrap: wrap; - } -} - -/* 配置查看模态框样式 */ -.config-view-modal { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: var(--overlay-bg); - backdrop-filter: blur(4px); - display: flex; - justify-content: center; - align-items: center; - z-index: 1000; - opacity: 0; - visibility: hidden; - transition: all 0.3s ease; -} - -.config-view-modal.show { - opacity: 1; - visibility: visible; -} - -.config-modal-content { - background: var(--bg-primary); - border-radius: 0.5rem; - width: 90%; - max-width: 800px; - max-height: 80vh; - overflow: hidden; - box-shadow: 0 20px 60px var(--neutral-shadow-30); - display: flex; - flex-direction: column; - animation: modalSlideIn 0.3s ease; -} - -@keyframes modalSlideIn { - from { - transform: translateY(-50px); - opacity: 0; - } - to { - transform: translateY(0); - opacity: 1; - } -} - -.config-modal-header { - padding: 1.5rem; - border-bottom: 1px solid var(--border-color); - display: flex; - justify-content: space-between; - align-items: center; - background: var(--bg-secondary); -} - -.config-modal-header h3 { - margin: 0; - font-size: 1.25rem; - font-weight: 600; - color: var(--text-primary); -} - -.config-modal-body { - padding: 1.5rem; - flex: 1; - overflow-y: auto; -} - -.config-file-info { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 1rem; - margin-bottom: 1.5rem; - padding: 1rem; - background: var(--bg-secondary); - border-radius: 0.5rem; -} - -.file-info-item { - display: flex; - flex-direction: column; - gap: 0.25rem; -} - -.file-info-item .info-label { - font-size: 0.75rem; - color: var(--text-secondary); - font-weight: 500; - text-transform: uppercase; - letter-spacing: 0.05em; -} - -.file-info-item .info-value { - font-size: 0.875rem; - color: var(--text-primary); - font-family: 'Courier New', monospace; - word-break: break-all; -} - -.config-content { - margin-top: 1rem; -} - -.config-content label { - display: block; - font-size: 0.875rem; - font-weight: 500; - color: var(--text-primary); - margin-bottom: 0.5rem; -} - -.config-content-display { - background: var(--code-bg); - color: var(--code-text); - padding: 1rem; - border-radius: 0.5rem; - font-family: 'Courier New', monospace; - font-size: 0.875rem; - line-height: 1.5; - max-height: 400px; - overflow-y: auto; - white-space: pre-wrap; - word-wrap: break-word; -} - -.config-modal-footer { - padding: 1.5rem; - border-top: 1px solid var(--border-color); - display: flex; - justify-content: flex-end; - gap: 1rem; - background: var(--bg-secondary); -} - -/* 响应式调整 */ -@media (max-width: 768px) { - .config-modal-content { - width: 95%; - max-height: 90vh; - } - - .config-file-info { - grid-template-columns: 1fr; - } - - .config-modal-footer { - flex-direction: column; - } -} - -/* 关联信息显示样式 */ -.config-usage-info { - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: 0.5rem; - margin: 1rem 0; - padding: 1rem; - border-left: 4px solid var(--primary-color); -} - -.usage-info-header { - display: flex; - align-items: center; - gap: 0.5rem; - margin-bottom: 1rem; - padding-bottom: 0.75rem; - border-bottom: 1px solid var(--border-color); -} - -.usage-info-header i { - color: var(--primary-color); - font-size: 0.875rem; -} - -.usage-info-title { - font-size: 0.875rem; - font-weight: 600; - color: var(--text-primary); -} - -.usage-details-list { - display: flex; - flex-direction: column; - gap: 0.75rem; -} - -.usage-detail-item { - display: flex; - align-items: center; - gap: 0.75rem; - padding: 0.75rem; - background: var(--bg-primary); - border: 1px solid var(--border-color); - border-radius: 0.375rem; - transition: var(--transition); -} - -.usage-detail-item:hover { - border-color: var(--primary-color); - background: var(--bg-tertiary); -} - -.usage-detail-item i { - color: var(--primary-color); - font-size: 0.875rem; - width: 16px; - text-align: center; - flex-shrink: 0; -} - -.usage-detail-type { - font-size: 0.75rem; - font-weight: 600; - color: var(--text-primary); - background: var(--primary-color); - color: var(--white); - padding: 0.125rem 0.5rem; - border-radius: 0.25rem; - text-transform: uppercase; - letter-spacing: 0.05em; - flex-shrink: 0; -} - -.usage-detail-location { - font-size: 0.875rem; - color: var(--text-secondary); - font-family: 'Courier New', monospace; - word-break: break-all; - flex: 1; -} - -/* 针对不同使用类型的特殊样式 */ -.config-item-manager.used .config-usage-info { - border-left-color: var(--success-color); -} - -.config-item-manager.used .config-usage-info .usage-info-header i { - color: var(--success-color); -} - -.config-item-manager.used .usage-detail-item i { - color: var(--success-color); -} - -.config-item-manager.used .usage-detail-type { - background: var(--success-color); -} - -/* 提供商池关联的特殊样式 */ -.usage-detail-item[data-usage-type="provider_pool"] { - background: linear-gradient(135deg, var(--info-bg) 0%, var(--bg-primary) 100%); - border-color: var(--info-text); -} - -.usage-detail-item[data-usage-type="provider_pool"] i { - color: var(--info-text); -} - -.usage-detail-item[data-usage-type="provider_pool"] .usage-detail-type { - background: var(--info-text); -} - -/* 主要配置关联的特殊样式 */ -.usage-detail-item[data-usage-type="main_config"] { - background: linear-gradient(135deg, var(--success-bg-light) 0%, var(--bg-primary) 100%); - border-color: var(--success-color); -} - -.usage-detail-item[data-usage-type="main_config"] i { - color: var(--success-color); -} - -.usage-detail-item[data-usage-type="main_config"] .usage-detail-type { - background: var(--success-color); -} - -/* 多种用途的特殊样式 */ -.usage-detail-item[data-usage-type="multiple"] { - background: linear-gradient(135deg, var(--warning-bg) 0%, var(--bg-primary) 100%); - border-color: var(--warning-color); -} - -.usage-detail-item[data-usage-type="multiple"] i { - color: var(--warning-color); -} - -.usage-detail-item[data-usage-type="multiple"] .usage-detail-type { - background: var(--warning-color); -} - -/* 删除确认模态框样式 */ -.delete-confirm-modal { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: var(--overlay-bg); - backdrop-filter: blur(4px); - display: flex; - justify-content: center; - align-items: center; - z-index: 1000; - opacity: 0; - visibility: hidden; - transition: all 0.3s ease; -} - -.delete-confirm-modal.show { - opacity: 1; - visibility: visible; -} - -.delete-modal-content { - background: var(--bg-primary); - border-radius: 0.75rem; - width: 90%; - max-width: 600px; - max-height: 85vh; - overflow: hidden; - box-shadow: 0 25px 80px var(--neutral-shadow-40); - display: flex; - flex-direction: column; - animation: modalSlideIn 0.3s ease; - border: 2px solid transparent; -} - -.delete-confirm-modal.used .delete-modal-content { - border-color: var(--danger-color); - box-shadow: 0 25px 80px var(--danger-30); -} - -.delete-confirm-modal.unused .delete-modal-content { - border-color: var(--warning-color); - box-shadow: 0 25px 80px var(--warning-20); -} - -@keyframes modalSlideIn { - from { - transform: translateY(-50px) scale(0.9); - opacity: 0; - } - to { - transform: translateY(0) scale(1); - opacity: 1; - } -} - -.delete-modal-header { - padding: 1.5rem 2rem; - border-bottom: 1px solid var(--border-color); - display: flex; - justify-content: space-between; - align-items: center; - background: var(--bg-secondary); -} - -.delete-confirm-modal.used .delete-modal-header { - background: linear-gradient(135deg, var(--danger-bg) 0%, var(--bg-primary) 100%); - border-bottom-color: var(--danger-border-light); -} - -.delete-confirm-modal.unused .delete-modal-header { - background: linear-gradient(135deg, var(--warning-bg) 0%, var(--bg-primary) 100%); - border-bottom-color: var(--warning-border-light); -} - -.delete-modal-header h3 { - margin: 0; - font-size: 1.25rem; - font-weight: 600; - display: flex; - align-items: center; - gap: 0.75rem; -} - -.delete-confirm-modal.used .delete-modal-header h3 { - color: var(--danger-color); -} - -.delete-confirm-modal.unused .delete-modal-header h3 { - color: var(--warning-text-dark); -} - -.delete-modal-body { - padding: 2rem; - flex: 1; - overflow-y: auto; - max-height: calc(85vh - 160px); -} - -.delete-warning { - display: flex; - align-items: flex-start; - gap: 1rem; - padding: 1.5rem; - border-radius: 0.5rem; - margin-bottom: 1.5rem; - border: 2px solid; -} - -.delete-warning.warning-used { - background: linear-gradient(135deg, var(--danger-bg-alt) 0%, var(--bg-primary) 100%); - border-color: var(--danger-border-light); - color: var(--danger-color); -} - -.delete-warning.warning-unused { - background: linear-gradient(135deg, var(--warning-bg-alt) 0%, var(--bg-primary) 100%); - border-color: var(--warning-border-light); - color: var(--warning-text-dark); -} - -.warning-icon { - font-size: 1.5rem; - margin-top: 0.25rem; - flex-shrink: 0; -} - -.warning-content h4 { - margin: 0 0 0.75rem 0; - font-size: 1rem; - font-weight: 600; -} - -.warning-content p { - margin: 0; - font-size: 0.875rem; - line-height: 1.5; -} - -.config-info { - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: 0.5rem; - padding: 1.5rem; - margin-bottom: 1.5rem; -} - -.config-info-item { - display: flex; - justify-content: space-between; - align-items: center; - padding: 0.5rem 0; - border-bottom: 1px solid var(--border-color); -} - -.config-info-item:last-child { - border-bottom: none; -} - -.config-info-item .info-label { - font-size: 0.875rem; - color: var(--text-secondary); - font-weight: 500; -} - -.config-info-item .info-value { - font-size: 0.875rem; - color: var(--text-primary); - font-weight: 600; - font-family: 'Courier New', monospace; - text-align: right; - max-width: 60%; - word-break: break-all; -} - -.config-info-item .info-value.status-used { - color: var(--success-color); - background: var(--success-bg); - padding: 0.25rem 0.5rem; - border-radius: 0.25rem; - font-size: 0.75rem; - text-transform: uppercase; - letter-spacing: 0.05em; -} - -.config-info-item .info-value.status-unused { - color: var(--warning-color); - background: var(--warning-bg); - padding: 0.25rem 0.5rem; - border-radius: 0.25rem; - font-size: 0.75rem; - text-transform: uppercase; - letter-spacing: 0.05em; -} - -.usage-alert { - display: flex; - align-items: flex-start; - gap: 1rem; - padding: 1.5rem; - background: linear-gradient(135deg, var(--danger-bg) 0%, var(--danger-bg-alt) 100%); - border: 1px solid var(--danger-border-light); - border-radius: 0.5rem; - margin-top: 1rem; -} - -.alert-icon { - font-size: 1.25rem; - color: var(--danger-color); - margin-top: 0.125rem; - flex-shrink: 0; -} - -.alert-content h5 { - margin: 0 0 0.75rem 0; - font-size: 0.875rem; - font-weight: 600; - color: var(--danger-color); -} - -.alert-content p { - margin: 0 0 0.75rem 0; - font-size: 0.875rem; - color: var(--danger-text-dark); - line-height: 1.5; -} - -.alert-content ul { - margin: 0 0 0.75rem 1.5rem; - padding: 0; - font-size: 0.875rem; - color: var(--danger-text-dark); -} - -.alert-content li { - margin-bottom: 0.25rem; -} - -.alert-content strong { - color: var(--danger-color); - font-weight: 600; -} - -.delete-modal-footer { - padding: 1.5rem 2rem; - border-top: 1px solid var(--border-color); - display: flex; - justify-content: flex-end; - gap: 1rem; - background: var(--bg-secondary); -} - -.delete-confirm-modal.used .delete-modal-footer { - background: linear-gradient(135deg, var(--danger-bg) 0%, var(--bg-secondary) 100%); - border-top-color: var(--danger-border-light); -} - -.delete-confirm-modal.unused .delete-modal-footer { - background: linear-gradient(135deg, var(--warning-bg) 0%, var(--bg-secondary) 100%); - border-top-color: var(--warning-border-light); -} - -.btn-cancel-delete { - background: var(--bg-tertiary); - color: var(--text-primary); - border: 1px solid var(--border-color); -} - -.btn-cancel-delete:hover { - background: var(--neutral-200); - transform: translateY(-1px); -} - -.btn-confirm-delete { - position: relative; - overflow: hidden; -} - -.delete-confirm-modal.used .btn-confirm-delete { - background: linear-gradient(135deg, var(--warning-color) 0%, var(--warning-text-dark) 100%); - color: var(--white); - box-shadow: 0 4px 15px var(--warning-40); - animation: pulseDanger 2s infinite; -} - -.delete-confirm-modal.unused .btn-confirm-delete { - /* background: linear-gradient(135deg, var(--warning-text-dark) 0%, var(--warning-alt) 100%); */ - color: var(--white); - box-shadow: 0 4px 15px var(--warning-30); -} - -.delete-confirm-modal.used .btn-confirm-delete:hover { - background: linear-gradient(135deg, var(--warning-text-dark) 0%, var(--warning-text-darker) 100%); - transform: translateY(-2px); - box-shadow: 0 6px 20px var(--warning-50); -} - -.delete-confirm-modal.unused .btn-confirm-delete:hover { - /* background: linear-gradient(135deg, var(--warning-text-darker) 0%, var(--warning-text) 100%); */ - transform: translateY(-2px); - box-shadow: 0 6px 20px var(--warning-40); -} - -@keyframes pulseDanger { - 0%, 100% { - box-shadow: 0 4px 15px var(--danger-40); - } - 50% { - box-shadow: 0 4px 15px var(--danger-70); - } -} - -/* 响应式调整 */ -@media (max-width: 768px) { - .usage-detail-item { - flex-direction: column; - align-items: flex-start; - gap: 0.5rem; - } - - .usage-detail-type { - align-self: flex-start; - } - - .usage-detail-location { - align-self: stretch; - } - - .delete-modal-content { - width: 95%; - max-height: 90vh; - } - - .delete-modal-body { - padding: 1.5rem; - } - - .delete-modal-header, - .delete-modal-footer { - padding: 1rem 1.5rem; - } - - .config-info { - padding: 1rem; - } - - .config-info-item { - flex-direction: column; - align-items: flex-start; - gap: 0.25rem; - } - - .config-info-item .info-value { - max-width: 100%; - text-align: left; - } - - .delete-modal-footer { - flex-direction: column; - } - - .delete-warning, - .usage-alert { - flex-direction: column; - gap: 0.75rem; - } -} - -/* 禁用提供商状态样式 */ -.provider-item-detail.disabled { - opacity: 0.6; - background: linear-gradient(135deg, var(--neutral-100) 0%, var(--bg-primary) 100%); - border: 1px solid var(--neutral-300); - position: relative; -} - -.provider-item-detail.disabled::before { - content: ''; - position: absolute; - left: 0; - top: 0; - bottom: 0; - width: 4px; - background: linear-gradient(135deg, var(--neutral-500) 0%, var(--neutral-600) 100%); - border-radius: 0 2px 2px 0; - z-index: 1; -} - -.provider-item-detail.disabled:hover { - opacity: 0.8; - border-color: var(--neutral-400); - box-shadow: 0 2px 8px var(--neutral-shadow-lg); - transform: none; -} - -.provider-item-detail.disabled .provider-item-header { - background: linear-gradient(135deg, var(--neutral-200) 0%, var(--bg-primary) 100%); -} - -.provider-item-detail.disabled .provider-name { - color: var(--neutral-500); - text-decoration: line-through; -} - -.provider-item-detail.disabled .provider-meta { - color: var(--text-secondary); -} - -.disabled-status { - display: inline-flex; - align-items: center; - gap: 0.5rem; - font-weight: 500; - padding: 0.25rem 0.5rem; - border-radius: 6px; - transition: var(--transition); -} - -.provider-item-detail.disabled .disabled-status { - color: var(--neutral-500); - background: var(--primary-10); -} - -.provider-item-detail:not(.disabled) .disabled-status { - color: var(--primary-color); - background: var(--success-10); -} - -/* 禁用/启用按钮特殊样式 */ -.btn-warning { - background: linear-gradient(135deg, var(--warning-color) 0%, var(--warning-text-dark) 100%); - color: var(--white); - box-shadow: 0 2px 8px var(--warning-30); -} - -.btn-warning:hover { - transform: translateY(-1px); - box-shadow: 0 4px 12px var(--warning-40); -} - -.btn-warning:active { - transform: translateY(0); -} - -/* 健康检测按钮样式 */ -.btn-info { - background: linear-gradient(135deg, var(--info-color) 0%, var(--info-color-dark) 100%); - color: var(--white); -} - -.btn-info:hover { - transform: translateY(-1px); - box-shadow: 0 4px 12px var(--info-hover); -} - -.btn-info:active { - transform: translateY(0); -} - -/* 提供商状态指示器 */ -.provider-status .disabled-indicator { - position: relative; -} - -.provider-status .disabled-indicator::after { - content: ''; - position: absolute; - top: -2px; - right: -2px; - width: 8px; - height: 8px; - background: var(--neutral-500); - border: 2px solid var(--white); - border-radius: 50%; -} - -.provider-status .enabled-indicator::after { - background: var(--success-color); -} - -/* 禁用状态下的交互效果 */ -.provider-item-detail.disabled .provider-actions-group .btn-edit, -.provider-item-detail.disabled .provider-actions-group .btn-delete { - opacity: 0.5; - pointer-events: none; -} - -.provider-item-detail.disabled .provider-actions-group .btn-toggle { - opacity: 1; -} - -/* 统计信息中的禁用状态显示 */ -.provider-stat .disabled-count { - color: var(--neutral-500); - font-style: italic; -} - -.provider-stat .enabled-count { - color: var(--success-color); - font-weight: 600; -} - -/* 响应式调整 */ -@media (max-width: 768px) { - .provider-item-detail.disabled { - opacity: 0.5; - } - - .disabled-status { - font-size: 0.75rem; - padding: 0.2rem 0.4rem; - } - - .provider-actions-group { - flex-wrap: wrap; - gap: 0.5rem; - } - - .btn-small { - font-size: 0.7rem; - padding: 0.4rem 0.6rem; - } -} - -/* Dashboard Top Row Layout */ -.dashboard-top-row { - display: flex; - gap: 1.5rem; - margin-bottom: 2rem; - align-items: stretch; - justify-content: flex-start; -} - -.dashboard-top-row .stats-grid { - flex: 1; - margin-bottom: 0; - display: flex; - flex-direction: column; -} - -.dashboard-top-row .stats-grid .stat-card { - flex: 1; - height: 100%; -} - -.dashboard-top-row .dashboard-contact { - flex: 1; - margin-top: 0; - padding-top: 0; - border-top: none; -} - -.dashboard-top-row .dashboard-contact .contact-grid { - margin-top: 0; - height: 100%; - grid-template-columns: repeat(2, 1fr); - gap: 1rem; -} - -.dashboard-top-row .dashboard-contact .contact-card { - padding: 1.25rem; - height: 100%; - display: flex; - flex-direction: column; - justify-content: center; -} - -.dashboard-top-row .dashboard-contact .qr-container { - margin: 0.75rem 0; -} - -.dashboard-top-row .dashboard-contact .qr-code { - width: 100px; - height: 100px; -} - -.dashboard-top-row .dashboard-contact .contact-card h3 { - font-size: 1rem; -} - -.dashboard-top-row .dashboard-contact .qr-description { - font-size: 0.75rem; -} - -/* Contact and Sponsor Section */ -.contact-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); - gap: 2rem; - margin-top: 1.5rem; -} - -.contact-card { - background: var(--bg-primary); - padding: 2rem; - border-radius: 0.5rem; - box-shadow: var(--shadow-md); - text-align: center; - transition: var(--transition); - border: 1px solid var(--border-color); -} - -.contact-card:hover { - transform: translateY(-2px); - box-shadow: var(--shadow-lg); - border-color: var(--primary-color); -} - -.contact-card h3 { - font-size: 1.25rem; - font-weight: 600; - margin-bottom: 1rem; - color: var(--text-primary); - display: flex; - align-items: center; - justify-content: center; - gap: 0.5rem; -} - -.contact-card h3 i { - color: var(--primary-color); -} - -.qr-container { - margin: 1.5rem 0; - display: flex; - justify-content: center; -} - -.qr-code { - width: 200px; - height: 200px; - object-fit: contain; - border: 4px solid var(--border-color); - border-radius: 0.5rem; - padding: 0.5rem; - background: white; - transition: var(--transition); -} - -.qr-code:hover { - border-color: var(--primary-color); - transform: scale(1.05); -} - -.clickable-qr { - cursor: zoom-in; -} - -/* Image Zoom Overlay */ -.image-zoom-overlay { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: var(--neutral-shadow-85); - display: none; - justify-content: center; - align-items: center; - z-index: 2000; - cursor: zoom-out; - opacity: 0; - transition: opacity 0.3s ease; -} - -.image-zoom-overlay.show { - display: flex; - opacity: 1; -} - -.image-zoom-overlay img { - max-width: 90%; - max-height: 90%; - border-radius: 0.5rem; - box-shadow: 0 0 20px var(--neutral-shadow-50); - transform: scale(0.9); - transition: transform 0.3s ease; -} - -.image-zoom-overlay.show img { - transform: scale(1); -} - -.qr-description { - font-size: 0.875rem; - color: var(--text-secondary); - margin: 0; - line-height: 1.5; -} - -/* 响应式调整 */ -@media (max-width: 1024px) { - .dashboard-top-row { - flex-direction: column; - } - - .dashboard-top-row .stats-grid { - flex: none; - } - - .dashboard-top-row .dashboard-contact .qr-code { - width: 160px; - height: 160px; - } -} - -@media (max-width: 768px) { - .contact-grid { - grid-template-columns: 1fr; - gap: 1.5rem; - } - - .contact-card { - padding: 1.5rem; - } - - .qr-code { - width: 180px; - height: 180px; - } -} - -@media (max-width: 480px) { - .qr-code { - width: 150px; - height: 150px; - } - - .contact-card { - padding: 1rem; - } - -/* Contact section styling for dashboard */ -.contact-section { - margin-top: 2rem; - padding-top: 1.5rem; - border-top: 1px solid var(--border-color); -} - -.contact-section h3 { - font-size: 1.25rem; - font-weight: 600; - margin-bottom: 1.5rem; - color: var(--text-primary); - display: flex; - align-items: center; - gap: 0.5rem; -} - -.contact-section h3 i { - color: var(--primary-color); -} - -} - - -/* 不支持的模型选择器样式 */ -.not-supported-models-section { - grid-column: 1 / -1; - margin-top: 16px; -} - -.not-supported-models-section label { - display: flex; - align-items: center; - gap: 8px; - font-weight: 600; - color: var(--neutral-700); - margin-bottom: 12px; -} - -.not-supported-models-section .help-text { - font-size: 12px; - font-weight: normal; - color: var(--neutral-500); - margin-left: 4px; -} - -.not-supported-models-container { - background: var(--neutral-100); - border: 1px solid var(--neutral-300); - border-radius: 8px; - padding: 16px; - min-height: 100px; -} - -.models-loading { - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - color: var(--neutral-500); - padding: 20px; -} - -.models-checkbox-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); - gap: 12px; -} - -.model-checkbox-label { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 12px; - background: var(--white); - border: 1px solid var(--neutral-300); - border-radius: 6px; - cursor: pointer; - transition: all 0.2s ease; -} - -.model-checkbox-label:hover { - background: var(--neutral-200); - border-color: var(--neutral-400); -} - -.model-checkbox-label input[type="checkbox"] { - cursor: pointer; -} - -.model-checkbox-label input[type="checkbox"]:disabled { - cursor: not-allowed; -} - -.model-checkbox-label .model-name { - font-size: 13px; - color: var(--neutral-600); - user-select: none; -} - -.model-checkbox-label input[type="checkbox"]:checked + .model-name { - color: var(--danger-alt); - font-weight: 500; -} - -.no-models, -.error-message { - text-align: center; - padding: 20px; - color: var(--neutral-500); - font-size: 14px; -} - -.error-message { - color: var(--danger-alt); -} - -/* 编辑模式下的样式 */ -.provider-item-detail.editing .model-checkbox-label { - cursor: pointer; -} - -.provider-item-detail.editing .model-checkbox-label input[type="checkbox"] { - cursor: pointer; -} - -.provider-item-detail.editing .model-checkbox-label input[type="checkbox"]:not(:disabled) { - cursor: pointer; -} - -/* 全宽配置项 */ -.form-grid.full-width { - grid-column: 1 / -1; -} - -/* 授权按钮样式 - 与健康状态样式类似 */ -.generate-auth-btn { - display: inline-flex; - align-items: center; - gap: 0.5rem; - padding: 0.25rem 0.75rem; - background: var(--info-bg-light); - color: var(--info-text-dark); - border: none; - border-radius: 9999px; - font-size: 0.75rem; - font-weight: 500; - cursor: pointer; - transition: var(--transition); -} - -.generate-auth-btn:hover { - background: var(--info-hover); - color: var(--info-text-darker); - transform: translateY(-1px); -} - -.generate-auth-btn:active { - transform: translateY(0); -} - -.generate-auth-btn i { - font-size: 0.75rem; -} - -/* 授权模态框样式 */ -.modal-overlay { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: var(--overlay-bg); - backdrop-filter: blur(4px); - display: none; - justify-content: center; - align-items: center; - z-index: 1000; - animation: fadeIn 0.3s ease; -} - -.modal-content { - background: var(--bg-primary); - border-radius: 0.75rem; - width: 90%; - max-width: 600px; - max-height: 85vh; - overflow: hidden; - box-shadow: 0 25px 80px var(--neutral-shadow-40); - display: flex; - flex-direction: column; - animation: modalSlideIn 0.3s ease; -} - -.modal-header { - padding: 1.5rem 2rem; - border-bottom: 1px solid var(--border-color); - display: flex; - justify-content: space-between; - align-items: center; - background: linear-gradient(135deg, var(--info-bg-alt) 0%, var(--white) 100%); -} - -.modal-header h3 { - margin: 0; - font-size: 1.25rem; - font-weight: 600; - color: var(--text-primary); - display: flex; - align-items: center; - gap: 0.5rem; -} - -.modal-header h3 i { - color: var(--primary-color); -} - -.modal-close { - background: none; - border: none; - font-size: 1.5rem; - cursor: pointer; - color: var(--neutral-500); - padding: 0.5rem; - border-radius: 50%; - transition: var(--transition); - width: 2rem; - height: 2rem; - display: flex; - align-items: center; - justify-content: center; - line-height: 1; -} - -.modal-close:hover { - background: var(--neutral-200); - color: var(--neutral-600); - transform: rotate(90deg); -} - -.modal-body { - padding: 2rem; - flex: 1; - overflow-y: auto; -} - -.modal-footer { - padding: 1.5rem 2rem; - border-top: 1px solid var(--border-color); - display: flex; - justify-content: flex-end; - gap: 1rem; - background: linear-gradient(135deg, var(--bg-secondary) 0%, var(--white) 100%); -} - -.modal-cancel { - padding: 0.75rem 1.5rem; - background: var(--bg-tertiary); - color: var(--text-primary); - border: 1px solid var(--border-color); - border-radius: 0.375rem; - font-size: 0.875rem; - font-weight: 500; - cursor: pointer; - transition: var(--transition); -} - -.modal-cancel:hover { - background: var(--neutral-200); - border-color: var(--neutral-500); - transform: translateY(-1px); -} - -.open-auth-btn { - padding: 0.75rem 1.5rem; - background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); - color: white; - border: none; - border-radius: 0.375rem; - font-size: 0.875rem; - font-weight: 500; - cursor: pointer; - transition: var(--transition); - box-shadow: 0 2px 8px var(--primary-30); - display: inline-flex; - align-items: center; - gap: 0.5rem; -} - -.open-auth-btn:hover { - background: linear-gradient(135deg, var(--btn-primary-hover) 0%, var(--primary-color) 100%); - transform: translateY(-2px); - box-shadow: 0 4px 12px var(--primary-40); -} - -.open-auth-btn:active { - transform: translateY(0); -} - -.open-auth-btn i { - font-size: 0.875rem; -} - -/* 授权信息样式 */ -.auth-info { - display: flex; - flex-direction: column; - gap: 1.5rem; -} - -.auth-info p { - margin: 0; - font-size: 0.875rem; - color: var(--text-secondary); -} - -.auth-info strong { - color: var(--text-primary); - font-weight: 600; -} - -.auth-instructions { - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: 0.5rem; - padding: 1.5rem; - border-left: 4px solid var(--primary-color); -} - -.auth-instructions h4 { - margin: 0 0 1rem 0; - font-size: 1rem; - font-weight: 600; - color: var(--text-primary); -} - -.auth-instructions ol { - margin: 0 0 1rem 1.5rem; - padding: 0; -} - -.auth-instructions li { - margin-bottom: 0.5rem; - font-size: 0.875rem; - color: var(--text-secondary); - line-height: 1.5; -} - -.auth-note { - margin: 1rem 0 0 0; - padding: 0.75rem; - background: var(--warning-bg); - border: 1px solid var(--warning-border); - border-radius: 0.375rem; - font-size: 0.875rem; - color: var(--warning-text); -} - -.auth-url-section { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.auth-url-section label { - font-size: 0.875rem; - font-weight: 500; - color: var(--text-primary); -} - -.auth-url-container { - position: relative; - display: flex; - align-items: center; -} - -.auth-url-input { - flex: 1; - padding: 0.75rem; - padding-right: 3rem; - border: 1px solid var(--border-color); - border-radius: 0.375rem; - font-size: 0.875rem; - font-family: 'Courier New', monospace; - background: var(--bg-tertiary); - color: var(--text-primary); -} - -.auth-url-input:focus { - outline: none; - border-color: var(--primary-color); - box-shadow: 0 0 0 3px var(--primary-10); -} - -.auth-url-container .copy-btn { - position: absolute; - right: 0.5rem; - top: 50%; - transform: translateY(-50%); - background: var(--primary-color); - color: white; - border: none; - padding: 0.5rem; - border-radius: 0.25rem; - cursor: pointer; - transition: var(--transition); -} - -.auth-url-container .copy-btn:hover { - background: var(--btn-primary-hover); -} - -.open-auth-btn { - display: inline-flex; - align-items: center; - gap: 0.5rem; -} - -/* 响应式调整 */ -@media (max-width: 768px) { - .modal-content { - width: 95%; - max-height: 90vh; - } - - .modal-header, - .modal-body, - .modal-footer { - padding: 1rem 1.5rem; - } - - .auth-instructions { - padding: 1rem; - } - - .modal-footer { - flex-direction: column; - } - - .generate-auth-btn { - font-size: 0.75rem; - padding: 0.4rem 0.8rem; - } -} - - -/* ==================== 分页控件样式 ==================== */ -.pagination-container { - display: flex; - align-items: center; - justify-content: space-between; - padding: 12px 16px; - background: var(--bg-tertiary); - border-radius: 8px; - margin: 12px 0; - flex-wrap: wrap; - gap: 12px; -} - -.pagination-container.top { - margin-top: 0; -} - -.pagination-container.bottom { - margin-bottom: 0; -} - -.pagination-info { - font-size: 0.875rem; - color: var(--text-secondary); - white-space: nowrap; -} - -.pagination-controls { - display: flex; - align-items: center; - gap: 4px; -} - -.page-btn { - min-width: 36px; - height: 36px; - padding: 0 10px; - border: 1px solid var(--border-color); - background: var(--bg-primary); - color: var(--text-primary); - border-radius: 6px; - cursor: pointer; - font-size: 0.875rem; - font-weight: 500; - transition: var(--transition); - display: inline-flex; - align-items: center; - justify-content: center; -} - -.page-btn:hover:not(:disabled) { - background: var(--primary-color); - color: white; - border-color: var(--primary-color); -} - -.page-btn.active { - background: var(--primary-color); - color: white; - border-color: var(--primary-color); -} - -.page-btn:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.page-btn.nav-btn { - background: var(--bg-secondary); -} - -.page-ellipsis { - padding: 0 8px; - color: var(--text-secondary); -} - -.pagination-jump { - display: flex; - align-items: center; - gap: 8px; - font-size: 0.875rem; - color: var(--text-secondary); -} - -.page-jump-input { - width: 60px; - height: 36px; - padding: 0 8px; - border: 1px solid var(--border-color); - border-radius: 6px; - text-align: center; - font-size: 0.875rem; - background: var(--bg-primary); - color: var(--text-primary); -} - -.page-jump-input:focus { - outline: none; - border-color: var(--primary-color); - box-shadow: 0 0 0 3px var(--primary-10); -} - -/* 移除数字输入框的上下箭头 */ -.page-jump-input::-webkit-outer-spin-button, -.page-jump-input::-webkit-inner-spin-button { - -webkit-appearance: none; - margin: 0; -} - -.page-jump-input[type=number] { - -moz-appearance: textfield; -} - -/* 响应式分页 */ -@media (max-width: 768px) { - .pagination-container { - flex-direction: column; - align-items: stretch; - gap: 10px; - } - - .pagination-info { - text-align: center; - } - - .pagination-controls { - justify-content: center; - flex-wrap: wrap; - } - - .pagination-jump { - justify-content: center; - } -} - -/* ==================== 用量查询页面样式 ==================== */ -.usage-panel { - background: var(--bg-primary); - border-radius: 0.5rem; - box-shadow: var(--shadow-md); - padding: 1.5rem; -} - -.usage-controls { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 1.5rem; - padding-bottom: 1rem; - border-bottom: 1px solid var(--border-color); -} - -.usage-last-update { - font-size: 0.875rem; - color: var(--text-secondary); -} - -.usage-loading, -.usage-error, -.usage-empty { - text-align: center; - padding: 3rem; - color: var(--text-secondary); -} - -.usage-error { - color: var(--danger-color); - background: var(--danger-bg-light); - border-radius: 0.5rem; - border: 1px solid var(--danger-bg); -} - -.usage-empty i { - font-size: 3rem; - margin-bottom: 1rem; - color: var(--text-secondary); - opacity: 0.5; -} - -/* 提供商分组样式 */ -.usage-provider-group { - background: var(--bg-primary); - border: 1px solid var(--border-color); - border-radius: 0.5rem; - margin-bottom: 1rem; - overflow: hidden; -} - -.usage-group-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 0.75rem 1rem; - background: var(--bg-secondary); - user-select: none; - transition: var(--transition); -} - -.usage-group-header:hover { - background: var(--bg-tertiary); -} - -.usage-group-title { - display: flex; - 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 { - font-size: 0.75rem; - color: var(--text-secondary); - transition: transform 0.3s ease; - width: 1rem; -} - -.usage-provider-group:not(.collapsed) .toggle-icon { - transform: rotate(90deg); -} - -.usage-group-title .provider-icon { - font-size: 1.25rem; - color: var(--primary-color); -} - -.usage-group-title .provider-name { - font-weight: 600; - font-size: 1rem; - color: var(--text-primary); -} - -.usage-group-title .instance-count { - font-size: 0.875rem; - color: var(--text-secondary); - background: var(--bg-tertiary); - padding: 0.25rem 0.5rem; - border-radius: 9999px; -} - -.usage-group-title .success-count { - font-size: 0.75rem; - color: var(--text-secondary); - margin-left: auto; -} - -.usage-group-title .success-count.all-success { - color: var(--success-color); -} - -.usage-group-content { - padding: 1rem; - display: block; -} - -.usage-provider-group.collapsed .usage-group-content { - display: none; -} - -/* 用量卡片网格 - 一排四个 */ -.usage-cards-grid { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 1rem; -} - -@media (max-width: 1400px) { - .usage-cards-grid { - grid-template-columns: repeat(3, 1fr); - } -} - -@media (max-width: 1024px) { - .usage-cards-grid { - grid-template-columns: repeat(2, 1fr); - } -} - -@media (max-width: 640px) { - .usage-cards-grid { - grid-template-columns: 1fr; - } -} - -.stat-success { color: var(--success-color); } -.stat-error { color: var(--danger-color); } -.stat-total { color: var(--text-secondary); } - -/* 实例卡片 */ -.usage-instance-card { - background: var(--bg-primary); - border: 1px solid var(--border-color); - border-radius: 0.5rem; - overflow: hidden; - transition: var(--transition); - display: flex; - flex-direction: column; -} - -.usage-instance-card:hover { - box-shadow: var(--shadow-md); - transform: translateY(-2px); -} - -.usage-instance-card.error { - border-color: var(--danger-border); -} - -.usage-instance-card.success { -} - -.usage-instance-header { - padding: 0.75rem 1rem; - border-bottom: 1px solid var(--border-color); - background: linear-gradient(to right, var(--bg-tertiary), var(--bg-secondary)); -} - -.instance-header-top { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 0.375rem; -} - -.instance-provider-type { - display: flex; - align-items: center; - gap: 0.375rem; - font-size: 0.7rem; - color: var(--primary-color); - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; -} - -.instance-provider-type i { - font-size: 0.75rem; -} - -.instance-status-badges { - display: flex; - align-items: center; - gap: 0.375rem; -} - -.instance-name { - margin-bottom: 0.375rem; -} - -.instance-name-text { - font-size: 0.875rem; - font-weight: 600; - color: var(--text-primary); - display: block; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -/* 用户信息整合到卡片头部 */ -.instance-user-info { - display: flex; - align-items: center; - gap: 0.5rem; - flex-wrap: wrap; - padding-top: 0.375rem; - border-top: 1px dashed var(--border-color); - margin-top: 0.375rem; -} - -.user-email { - font-size: 0.7rem; - color: var(--text-secondary); - display: flex; - align-items: center; - gap: 0.25rem; - max-width: 180px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.user-email i { - font-size: 0.65rem; - color: var(--primary-color); -} - -.user-subscription { - font-size: 0.65rem; - padding: 0.125rem 0.375rem; - background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); - color: white; - border-radius: 9999px; - font-weight: 500; -} - -.status-success { color: var(--success-color); } -.status-error { color: var(--danger-color); } - -.badge { - font-size: 0.75rem; - padding: 0.125rem 0.5rem; - border-radius: 9999px; - font-weight: 500; -} - -.badge-healthy { - background: var(--success-bg); - color: var(--success-text); -} - -.badge-unhealthy { - background: var(--danger-bg); - color: var(--danger-text); -} - -.badge-disabled { - background: var(--bg-tertiary); - color: var(--text-secondary); -} - -.usage-instance-content { - padding: 0.75rem; - flex: 1; -} - -.usage-error-message { - display: flex; - align-items: center; - gap: 0.5rem; - color: var(--danger-color); - padding: 0.75rem; - background: var(--danger-bg-light); - border-radius: 0.375rem; - font-size: 0.75rem; -} - -/* 用量详情 - 紧凑布局 */ -.usage-details { - display: flex; - flex-direction: column; - gap: 0.75rem; -} - -.usage-section { - background: var(--bg-secondary); - padding: 0.75rem; - border-radius: 0.375rem; - border: 1px solid var(--border-color); -} - -.usage-section h4 { - font-size: 0.75rem; - font-weight: 600; - color: var(--text-secondary); - margin-bottom: 0.5rem; - display: flex; - align-items: center; - gap: 0.375rem; - text-transform: uppercase; - letter-spacing: 0.05em; -} - -.usage-section h4 i { - color: var(--primary-color); - font-size: 0.75rem; -} - -/* 信息网格 - 紧凑 */ -.usage-section .info-grid { - display: flex; - flex-direction: column; - gap: 0.375rem; -} - -.usage-section .info-item { - display: flex; - justify-content: space-between; - align-items: center; - padding-bottom: 0.375rem; - border-bottom: 1px solid var(--border-color); -} - -.usage-section .info-item:last-child { - border-bottom: none; - padding-bottom: 0; -} - -.usage-section .label { - font-size: 0.75rem; - color: var(--text-secondary); -} - -.usage-section .value { - font-size: 0.75rem; - font-weight: 600; - color: var(--text-primary); - text-align: right; - max-width: 60%; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.subscription-title { - color: var(--primary-color); -} - -/* 总用量样式 */ -.total-usage { - background: linear-gradient(135deg, var(--bg-primary) 0%, var(--bg-secondary) 100%); - border: 1px solid var(--border-color); -} - -.total-usage-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 0.5rem; -} - -.total-label { - font-size: 0.8rem; - font-weight: 600; - color: var(--text-primary); - display: flex; - align-items: center; - gap: 0.375rem; -} - -.total-label i { - color: var(--primary-color); -} - -.total-value { - font-size: 0.75rem; - font-weight: 600; - color: var(--text-secondary); - font-family: monospace; -} - -.total-percent { - text-align: right; - font-size: 0.7rem; - font-weight: 600; - color: var(--text-secondary); - margin-top: 0.25rem; -} - -/* 重置时间紧凑样式 */ -.reset-info-compact { - background: var(--bg-secondary); - padding: 0.5rem 0.75rem; - border-radius: 0.375rem; - border: 1px solid var(--border-color); -} - -.reset-info-row { - display: flex; - justify-content: space-between; - align-items: center; - padding: 0.25rem 0; -} - -.reset-info-row:first-child { - border-bottom: 1px solid var(--border-color); - padding-bottom: 0.375rem; - margin-bottom: 0.25rem; -} - -.reset-label { - font-size: 0.7rem; - color: var(--text-secondary); - display: flex; - align-items: center; - gap: 0.25rem; -} - -.reset-label i { - font-size: 0.65rem; - color: var(--primary-color); -} - -.reset-value { - font-size: 0.7rem; - font-weight: 600; - color: var(--text-primary); -} - -.reset-days { - color: var(--primary-color); - font-weight: 700; -} - -/* 过期时间行样式 */ -.expiration-row { - border-top: 1px dashed var(--border-color); - padding-top: 0.375rem; - margin-top: 0.25rem; -} - -.expiring-soon { - color: var(--danger-color) !important; - font-weight: 700; -} - -/* 用量明细 - 紧凑版 */ -.usage-breakdown-compact { - background: var(--bg-secondary); - padding: 0.5rem; - border-radius: 0.375rem; - border: 1px solid var(--border-color); -} - -.breakdown-item-compact { - padding: 0.5rem; - background: var(--bg-primary); - border-radius: 0.25rem; - margin-bottom: 0.5rem; - border: 1px solid var(--border-color); -} - -.breakdown-item-compact:last-child { - margin-bottom: 0; -} - -.breakdown-header-compact { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 0.375rem; - font-size: 0.7rem; -} - -.breakdown-header-compact .breakdown-name { - font-weight: 600; - color: var(--text-primary); -} - -.breakdown-header-compact .breakdown-usage { - color: var(--text-secondary); - font-family: monospace; -} - -.progress-bar-small { - height: 0.25rem; - background: var(--bg-tertiary); - border-radius: 9999px; - overflow: hidden; - margin-bottom: 0.375rem; -} - -.progress-bar-small .progress-fill { - height: 100%; - border-radius: 9999px; - transition: width 0.3s ease; -} - -.progress-bar-small.normal .progress-fill { background: var(--success-color); } -.progress-bar-small.warning .progress-fill { background: var(--warning-color); } -.progress-bar-small.danger .progress-fill { background: var(--danger-color); } - -/* 额外用量信息(免费试用、奖励) */ -.extra-usage-info { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 0.375rem; - font-size: 0.6rem; - padding: 0.375rem; - border-radius: 0.25rem; - margin-top: 0.375rem; -} - -.extra-usage-info.free-trial { - background: var(--info-bg-lighter); - color: var(--info-text); - border: 1px solid var(--info-bg); -} - -.extra-usage-info.bonus { - background: var(--warning-bg-alt); - color: var(--warning-text); - border: 1px solid var(--warning-bg); -} - -.extra-usage-info .extra-label { - font-weight: 600; - display: flex; - align-items: center; - gap: 0.25rem; -} - -.extra-usage-info .extra-label i { - font-size: 0.55rem; -} - -.extra-usage-info .extra-value { - font-family: monospace; - font-weight: 600; -} - -.extra-usage-info .extra-expires { - margin-left: auto; - opacity: 0.8; - font-size: 0.55rem; -} - -.breakdown-item { - margin-bottom: 0.75rem; - background: var(--bg-primary); - padding: 0.75rem; - border-radius: 0.375rem; - border: 1px solid var(--border-color); -} - -.breakdown-item:last-child { - margin-bottom: 0; -} - -.breakdown-header { - display: flex; - justify-content: space-between; - margin-bottom: 0.375rem; - font-size: 0.75rem; - font-weight: 500; - flex-wrap: wrap; - gap: 0.25rem; -} - -.breakdown-name { - color: var(--text-primary); -} - -.breakdown-usage { - color: var(--text-secondary); - font-family: monospace; - font-size: 0.7rem; -} - -.progress-bar { - height: 0.375rem; - background: var(--bg-tertiary); - border-radius: 9999px; - overflow: hidden; - margin-bottom: 0.5rem; -} - -.progress-fill { - height: 100%; - border-radius: 9999px; - transition: width 0.3s ease; -} - -.progress-bar.normal .progress-fill { background: var(--success-color); } -.progress-bar.warning .progress-fill { background: var(--warning-color); } -.progress-bar.danger .progress-fill { background: var(--danger-color); } - -/* 免费试用和奖励信息 - 紧凑 */ -.free-trial-info, -.bonus-item { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 0.375rem; - font-size: 0.65rem; - padding: 0.375rem; - background: var(--bg-tertiary); - border-radius: 0.25rem; - margin-top: 0.375rem; -} - -.free-trial-info { - background: var(--info-bg-lighter); - color: var(--info-text); - border: 1px solid var(--info-bg); -} - -.bonus-item { - background: var(--warning-bg-alt); - color: var(--warning-text); - border: 1px solid var(--warning-bg); -} - -.free-trial-info .label, -.bonus-item .bonus-name { - font-weight: 600; - display: flex; - align-items: center; - gap: 0.25rem; -} - -.free-trial-info .value, -.bonus-item .bonus-usage { - font-family: monospace; - font-weight: 600; -} - -.free-trial-info .expires, -.bonus-item .bonus-expires { - margin-left: auto; - color: currentColor; - opacity: 0.8; - font-size: 0.6rem; -} - -.bonuses-info { - margin-top: 0.5rem; - display: flex; - flex-direction: column; - 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.25rem; - flex-wrap: wrap; -} - -.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); - width: 100%; - margin-top: 0.125rem; - text-align: center; -} - -.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 { - padding: 0.5rem 0.75rem; - } - - .instance-name-text { - max-width: 120px; - } - - .usage-instance-content { - padding: 0.5rem; - } -} - -/* ==================== 语言切换器样式 ==================== */ -.language-switcher { - position: relative; - display: inline-block; -} - -.language-btn { - display: inline-flex; - align-items: center; - gap: 0.5rem; - padding: 0.5rem 1rem; - background: transparent; - color: var(--text-secondary); - border: 1px solid var(--border-color); - border-radius: 0.375rem; - cursor: pointer; - font-size: 0.875rem; - font-weight: 500; - transition: var(--transition); -} - -.language-btn:hover { - background: var(--bg-tertiary); - color: var(--primary-color); - border-color: var(--primary-color); - transform: translateY(-1px); -} - -.language-btn i { - font-size: 1rem; -} - -.current-lang { - font-weight: 600; -} - -.language-dropdown { - position: absolute; - top: calc(100% + 0.5rem); - right: 0; - background: var(--bg-primary); - border: 1px solid var(--border-color); - border-radius: 0.5rem; - box-shadow: var(--shadow-lg); - min-width: 150px; - opacity: 0; - visibility: hidden; - transform: translateY(-10px); - transition: all 0.3s ease; - z-index: 1000; -} - -.language-dropdown.show { - opacity: 1; - visibility: visible; - transform: translateY(0); -} - -.language-option { - display: flex; - align-items: center; - gap: 0.75rem; - width: 100%; - padding: 0.75rem 1rem; - background: none; - border: none; - text-align: left; - cursor: pointer; - font-size: 0.875rem; - color: var(--text-primary); - transition: var(--transition); - position: relative; -} - -.language-option:first-child { - border-radius: 0.5rem 0.5rem 0 0; -} - -.language-option:last-child { - border-radius: 0 0 0.5rem 0.5rem; -} - -.language-option:hover { - background: var(--bg-secondary); -} - -.language-option i { - font-size: 0.875rem; - color: var(--primary-color); - opacity: 0; - transition: opacity 0.3s ease; -} - -.language-option.active i { - opacity: 1; -} - -.language-option span { - flex: 1; -} - -/* 响应式调整 */ -@media (max-width: 768px) { - .language-btn { - padding: 0.5rem 0.75rem; - font-size: 0.8rem; - } - - .language-btn .current-lang { - display: none; - } - - .language-dropdown { - right: auto; - left: 0; - } -} - -/* ==================== 重启提示模态框样式 ==================== */ -.restart-required-modal .restart-modal-content { - max-width: 550px; - border: 2px solid var(--primary-color); - box-shadow: 0 25px 80px var(--primary-30); -} - -.restart-required-modal .restart-modal-header { - background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); - color: white; - border-bottom: none; -} - -.restart-required-modal .restart-modal-header h3 { - color: white; -} - -.restart-required-modal .restart-modal-header h3 i { - color: white; -} - -.restart-required-modal .modal-close { - color: white; -} - -.restart-required-modal .modal-close:hover { - background: var(--white-20); - color: var(--white); -} - -.restart-icon-container { - text-align: center; - margin-bottom: 1.5rem; -} - -.restart-icon-container i { - font-size: 3rem; - color: var(--primary-color); - animation: spin 2s linear infinite; -} - -@keyframes spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -.restart-notice { - background: linear-gradient(135deg, var(--success-bg) 0%, var(--success-bg-light) 100%); - border: 1px solid var(--secondary-color); - border-radius: 0.5rem; - padding: 1rem; - margin-bottom: 1rem; - border-left: 4px solid var(--primary-color); -} - -.restart-notice p { - margin: 0; - color: var(--success-text); - font-weight: 500; - display: flex; - align-items: center; - gap: 0.5rem; -} - -.restart-notice i { - color: var(--primary-color); -} - -.restart-instructions { - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: 0.5rem; - padding: 1rem; -} - -.restart-instructions p { - margin: 0; - color: var(--text-primary); - white-space: pre-line; - line-height: 1.6; - font-size: 0.875rem; -} - -.restart-confirm-btn { - background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); - color: white; - border: none; - padding: 0.75rem 1.5rem; - border-radius: 0.375rem; - font-size: 0.875rem; - font-weight: 500; - cursor: pointer; - transition: var(--transition); - box-shadow: 0 2px 8px var(--primary-30); - display: inline-flex; - align-items: center; - gap: 0.5rem; -} - -.restart-confirm-btn:hover { - background: linear-gradient(135deg, var(--btn-primary-hover) 0%, var(--primary-color) 100%); - transform: translateY(-2px); - box-shadow: 0 4px 12px var(--primary-40); -} - -.restart-confirm-btn:active { - transform: translateY(0); -} - -/* 响应式调整 */ -@media (max-width: 768px) { - .restart-required-modal .restart-modal-content { - max-width: 95%; - } - - .restart-icon-container i { - font-size: 2.5rem; - } - - .restart-notice, - .restart-instructions { - 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, var(--warning-bg) 0%, var(--warning-bg-light) 100%); - border-color: var(--warning-border); - color: var(--warning-text); -} - -[data-theme="dark"] .highlight-note i { - color: var(--warning-color); -} - -/* 提供商徽章 - 暗黑主题 */ -[data-theme="dark"] .provider-badge.official { - background: var(--info-bg); - color: var(--info-text); -} - -[data-theme="dark"] .provider-badge.oauth { - background: var(--success-bg); - color: var(--success-text); -} - -[data-theme="dark"] .provider-badge.responses { - background: var(--warning-bg); - color: var(--warning-text); -} - -/* 状态徽章 - 暗黑主题 */ -[data-theme="dark"] .status-healthy { - background: var(--success-bg); - color: var(--success-text); -} - -[data-theme="dark"] .status-unhealthy { - background: var(--danger-bg); - color: var(--danger-text); -} - -[data-theme="dark"] .status-used { - background: var(--success-bg); - color: var(--success-text); -} - -[data-theme="dark"] .status-unused { - background: var(--warning-bg); - color: var(--warning-text); -} - -[data-theme="dark"] .status-invalid { - background: var(--danger-bg); - color: var(--danger-text); -} - -/* 更新徽章 - 暗黑主题 */ -[data-theme="dark"] .update-badge { - background: var(--warning-bg); - color: var(--warning-text); - border-color: var(--warning-border); -} - -/* 模态框 - 暗黑主题 */ -[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, var(--warning-bg) 0%, var(--bg-primary) 100%); -} - -[data-theme="dark"] .provider-item-detail.unhealthy .provider-item-header { - background: linear-gradient(135deg, var(--warning-bg) 0%, var(--bg-primary) 100%); -} - -/* 错误信息 - 暗黑主题 */ -[data-theme="dark"] .provider-error-info { - background: linear-gradient(135deg, var(--danger-bg) 0%, var(--danger-bg-medium) 100%); - border-color: var(--danger-border); -} - -[data-theme="dark"] .provider-error-info i { - color: var(--danger-icon); -} - -[data-theme="dark"] .provider-error-info .error-label { - color: var(--danger-label); -} - -[data-theme="dark"] .provider-error-info .error-message { - color: var(--danger-text-dark); -} - -/* 路由提示 - 暗黑主题 */ -[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, var(--danger-bg) 0%, var(--bg-primary) 100%); - border-bottom-color: var(--danger-border); -} - -[data-theme="dark"] .delete-confirm-modal.unused .delete-modal-header { - background: linear-gradient(135deg, var(--warning-bg) 0%, var(--bg-primary) 100%); - border-bottom-color: var(--warning-border); -} - -[data-theme="dark"] .delete-warning.warning-used { - background: linear-gradient(135deg, var(--danger-bg) 0%, var(--bg-primary) 100%); - border-color: var(--danger-border); - color: var(--danger-text); -} - -[data-theme="dark"] .delete-warning.warning-unused { - background: linear-gradient(135deg, var(--warning-bg) 0%, var(--bg-primary) 100%); - border-color: var(--warning-border); - color: var(--warning-text); -} - -[data-theme="dark"] .usage-alert { - background: linear-gradient(135deg, var(--danger-bg) 0%, var(--danger-bg-medium) 100%); - border-color: var(--danger-border); -} - -[data-theme="dark"] .alert-content h5 { - color: var(--danger-text); -} - -[data-theme="dark"] .alert-content p, -[data-theme="dark"] .alert-content ul { - color: var(--danger-text-light); -} - -/* 配置信息 - 暗黑主题 */ -[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: var(--success-bg); - color: var(--success-text); -} - -[data-theme="dark"] .config-info-item .info-value.status-unused { - background: var(--warning-bg); - color: var(--warning-text); -} - -/* 授权模态框 - 暗黑主题 */ -[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: var(--warning-bg); - border-color: var(--warning-border); - color: var(--warning-text); -} - -[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, var(--success-bg) 0%, var(--success-bg-light) 100%); - border-color: var(--secondary-color); -} - -[data-theme="dark"] .restart-notice p { - color: var(--success-text); -} - -[data-theme="dark"] .restart-instructions { - background: var(--bg-secondary); - border-color: var(--border-color); -} - -/* 用量卡片 - 暗黑主题 */ -[data-theme="dark"] .usage-instance-card.error { - border-color: var(--danger-border); -} - -[data-theme="dark"] .usage-instance-header { - background: linear-gradient(to right, var(--bg-tertiary), var(--bg-secondary)); -} - -[data-theme="dark"] .usage-error-message { - background: var(--danger-bg); - color: var(--danger-text); -} - -[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: var(--info-bg); - color: var(--info-text); - border-color: var(--info-border); -} - -[data-theme="dark"] .extra-usage-info.bonus { - background: var(--warning-bg); - color: var(--warning-text); - border-color: var(--warning-border); -} - -/* 分页控件 - 暗黑主题 */ -[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: var(--neutral-shadow-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 var(--primary-10); -} - -[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; -} - -/* ==================== 插件管理样式 ==================== */ -.plugins-panel { - background: var(--bg-primary); - padding: 2rem; - border-radius: 0.5rem; - box-shadow: var(--shadow-md); -} - -.plugins-description { - margin-bottom: 2rem; -} - -.plugins-stats { - margin-bottom: 2rem; -} - -.plugins-controls { - display: flex; - justify-content: flex-end; - margin-bottom: 1.5rem; -} - -.plugins-list-container { - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: 0.5rem; - overflow: hidden; -} - -.plugins-list { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); - gap: 1.5rem; - padding: 1.5rem; -} - -.plugin-card { - background: var(--bg-primary); - border: 1px solid var(--border-color); - border-radius: 0.5rem; - padding: 1.5rem; - transition: var(--transition); - display: flex; - flex-direction: column; - gap: 1rem; -} - -.plugin-card:hover { - transform: translateY(-2px); - box-shadow: var(--shadow-md); - border-color: var(--primary-color); -} - -.plugin-header { - display: flex; - justify-content: space-between; - align-items: flex-start; -} - -.plugin-title h3 { - margin: 0; - font-size: 1.125rem; - font-weight: 600; - color: var(--text-primary); -} - -.plugin-version { - font-size: 0.75rem; - color: var(--text-secondary); - background: var(--bg-tertiary); - padding: 0.125rem 0.5rem; - border-radius: 9999px; - margin-top: 0.25rem; - display: inline-block; -} - -.plugin-description { - font-size: 0.875rem; - color: var(--text-secondary); - line-height: 1.5; - flex: 1; -} - -.plugin-badges { - display: flex; - gap: 0.5rem; - flex-wrap: wrap; -} - -.plugin-badge { - font-size: 0.75rem; - padding: 0.25rem 0.5rem; - border-radius: 0.25rem; - font-weight: 500; - text-transform: uppercase; - letter-spacing: 0.05em; -} - -.plugin-badge.middleware { - background: var(--info-bg); - color: var(--info-text); -} - -.plugin-badge.routes { - background: var(--success-bg); - color: var(--success-text); -} - -.plugin-badge.hooks { - background: var(--warning-bg); - color: var(--warning-text); -} - -.plugin-status { - display: flex; - align-items: center; - gap: 0.5rem; - font-size: 0.875rem; - font-weight: 500; - padding-top: 1rem; - border-top: 1px solid var(--border-color); -} - -.plugin-card.enabled .plugin-status { - color: var(--success-color); -} - -.plugin-card.disabled .plugin-status { - color: var(--text-secondary); -} - -.plugin-card.disabled { - opacity: 0.8; - background: var(--bg-secondary); -} - -.plugins-loading, -.plugins-empty { - text-align: center; - padding: 3rem; - color: var(--text-secondary); -} - -.plugins-empty { - display: none; - flex-direction: column; - align-items: center; - gap: 1rem; -} - -.plugins-empty i { - font-size: 3rem; - opacity: 0.5; -} - -/* 暗黑主题适配 */ -[data-theme="dark"] .plugins-list-container { - background: var(--bg-secondary); -} - -[data-theme="dark"] .plugin-card { - background: var(--bg-primary); -} - -[data-theme="dark"] .plugin-card.disabled { - background: var(--bg-tertiary); -} - -[data-theme="dark"] .plugin-badge.middleware { - background: var(--info-bg); - color: var(--info-text); -} - -[data-theme="dark"] .plugin-badge.routes { - background: var(--success-bg); - color: var(--success-text); -} - -[data-theme="dark"] .plugin-badge.hooks { - background: var(--warning-bg); - color: var(--warning-text); -} diff --git a/static/components/header.css b/static/components/header.css new file mode 100644 index 0000000..e06b213 --- /dev/null +++ b/static/components/header.css @@ -0,0 +1,161 @@ +/* Header - 玻璃拟态效果 */ +.header { + background: var(--bg-glass); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border-bottom: 1px solid var(--border-color); + position: sticky; + top: 0; + z-index: 100; + transition: var(--transition); +} + +.header-content { + max-width: 1600px; + margin: 0 auto; + padding: 0.75rem 2rem; + display: flex; + justify-content: space-between; + align-items: center; +} + +.header h1 { + font-size: 1.25rem; + font-weight: 700; + color: var(--text-primary); + display: flex; + align-items: center; + letter-spacing: -0.025em; +} + +.header h1 i { + margin-right: 0.5rem; +} + +.header-controls { + display: flex; + gap: 1rem; + align-items: center; +} + +.status-badge { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + background: var(--bg-tertiary); + border-radius: var(--radius-full); + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.status-badge i { + color: var(--success-color); + animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; + font-size: 0.6rem; +} + +.status-badge.error i { + color: var(--danger-color); +} + +.logout-btn { + padding: 0.5rem 1rem; + background: transparent; + color: var(--text-secondary); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + cursor: pointer; + font-size: 0.875rem; + font-weight: 600; + transition: var(--transition); + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +.logout-btn:hover { + background: var(--bg-tertiary); + color: var(--danger-color); + border-color: var(--danger-color); +} + +.logout-btn:active { + transform: scale(0.98); +} + +.logout-btn i { + font-size: 14px; +} + +.github-link { + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + padding: 0; + background: transparent; + color: var(--text-secondary); + border: 1px solid var(--border-color); + border-radius: 50%; + cursor: pointer; + font-size: 1.125rem; + transition: var(--transition); + text-decoration: none; + position: relative; + overflow: hidden; +} + +.github-link:hover { + background: var(--bg-tertiary); + color: var(--primary-color); + border-color: var(--primary-color); + transform: translateY(-2px); + box-shadow: var(--shadow-md); +} + +.github-link:active { + transform: translateY(0); +} + +.github-link i { + transition: transform 0.3s ease; +} + +/* KIRO 购买链接 */ +.kiro-buy-link { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + background: linear-gradient(135deg, var(--warning-color) 0%, #f97316 100%); + color: var(--white); + text-decoration: none; + border-radius: 9999px; + font-size: 0.875rem; + font-weight: 600; + transition: var(--transition); + box-shadow: 0 2px 8px var(--warning-30); +} + +.kiro-buy-link:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px var(--warning-40); + background: linear-gradient(135deg, #f97316 0%, var(--warning-color) 100%); +} + +.kiro-buy-link:active { + transform: translateY(0); +} + +.kiro-buy-link i { + font-size: 0.875rem; +} + +/* 暗黑主题适配 */ +[data-theme="dark"] .header { + background: var(--bg-glass); +} diff --git a/static/components/header.html b/static/components/header.html new file mode 100644 index 0000000..210ba8e --- /dev/null +++ b/static/components/header.html @@ -0,0 +1,31 @@ + + +
+
+

AIClient2API 管理控制台

+ +
+ + KIRO账号购买 + + + 连接中... + + + + + + + +
+
+
\ No newline at end of file diff --git a/static/components/section-config.css b/static/components/section-config.css new file mode 100644 index 0000000..d8ca61c --- /dev/null +++ b/static/components/section-config.css @@ -0,0 +1,383 @@ +/* 表单样式 */ +.config-panel { + background: var(--bg-primary); + padding: 2rem; + border-radius: var(--radius-xl); + box-shadow: var(--shadow-sm); + border: 1px solid var(--border-color); +} + +.config-form { + max-width: 800px; + margin: 0 auto; +} + +.form-group { + margin-bottom: 1.5rem; +} + +.form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.5rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + font-weight: 600; + color: var(--text-primary); + font-size: 0.9rem; +} + +.optional-tag, .form-group label .optional-mark { + font-size: 0.75rem; + color: var(--text-tertiary); + font-weight: 400; + margin-left: 0.5rem; + background: var(--bg-tertiary); + padding: 0.125rem 0.375rem; + border-radius: var(--radius-sm); +} + +.form-control::placeholder { + color: var(--text-tertiary); +} + +textarea.form-control { + resize: vertical; + font-family: inherit; +} + +/* 密码输入框样式 */ +.password-input-group { + position: relative; +} + +.password-input-wrapper { + position: relative; + display: flex; + align-items: center; +} + +.password-input-wrapper .form-control { + padding-right: 3rem; +} + +.password-input-wrapper input[type="password"], +.password-input-wrapper input[type="text"] { + flex: 1; + padding-right: 3rem; +} + +.password-toggle { + position: absolute; + right: 0.75rem; + background: none; + border: none; + cursor: pointer; + padding: 0.25rem; + color: var(--text-secondary); + transition: var(--transition); + z-index: 1; + width: auto; + flex-shrink: 0; +} + +.password-toggle:hover { + color: var(--primary-color); +} + +.password-toggle i { + font-size: 1rem; + width: 1rem; + text-align: center; +} + +/* 授权刷新切换开关 */ +.oauth-refresh-toggle { + display: flex; + align-items: center; + gap: 0.75rem; + margin-top: 0.5rem; +} + +.toggle-switch { + position: relative; + display: inline-block; + width: 48px; + height: 24px; +} + +.toggle-switch input { + opacity: 0; + width: 0; + height: 0; +} + +.toggle-slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: var(--bg-tertiary); + border: 1px solid var(--border-color); + transition: var(--transition); + border-radius: 24px; +} + +.toggle-slider:before { + position: absolute; + content: ""; + height: 18px; + width: 18px; + left: 3px; + bottom: 2px; + background-color: white; + transition: var(--transition); + border-radius: 50%; + box-shadow: 0 1px 3px var(--neutral-shadow-30); +} + +input:checked + .toggle-slider { + background-color: var(--primary-color); + border-color: var(--primary-color); +} + +input:checked + .toggle-slider:before { + transform: translateX(24px); +} + +.toggle-label { + font-weight: 500; + color: var(--text-primary); + font-size: 0.875rem; +} + +/* 系统提示区域 */ +.system-prompt-section { + margin-top: 2rem; + padding-top: 1.5rem; + border-top: 1px solid var(--border-color); +} + +.form-actions { + display: flex; + gap: 1rem; + margin-top: 2rem; + justify-content: flex-end; +} + +/* 重启提示模态框样式 */ +.restart-required-modal .restart-modal-content { + max-width: 550px; + border: 2px solid var(--primary-color); + box-shadow: 0 25px 80px var(--primary-30); +} + +.restart-required-modal .restart-modal-header { + background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); + color: white; + border-bottom: none; +} + +.restart-required-modal .restart-modal-header h3 { + color: white; +} + +.restart-required-modal .restart-modal-header h3 i { + color: white; +} + +.restart-icon-container { + text-align: center; + margin-bottom: 1.5rem; +} + +.restart-icon-container i { + font-size: 3rem; + color: var(--primary-color); + animation: spin 2s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +.restart-notice { + background: linear-gradient(135deg, var(--success-bg) 0%, var(--success-bg-light) 100%); + border: 1px solid var(--secondary-color); + border-radius: 0.5rem; + padding: 1rem; + margin-bottom: 1rem; + border-left: 4px solid var(--primary-color); +} + +.restart-notice p { + margin: 0; + color: var(--success-text); + font-weight: 500; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.restart-instructions { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 0.5rem; + padding: 1rem; +} + +.restart-instructions p { + margin: 0; + color: var(--text-primary); + white-space: pre-line; + line-height: 1.6; + font-size: 0.875rem; +} + +.restart-confirm-btn { + background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); + color: white; + border: none; + padding: 0.75rem 1.5rem; + border-radius: 0.375rem; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: var(--transition); + box-shadow: 0 2px 8px var(--primary-30); + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +.restart-confirm-btn:hover { + background: linear-gradient(135deg, var(--btn-primary-hover) 0%, var(--primary-color) 100%); + transform: translateY(-2px); + box-shadow: 0 4px 12px var(--primary-40); +} + +/* 复选框列表样式 */ +.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; +} + +/* 高级配置区域 */ +.advanced-config-section { + border: 1px solid var(--border-color); + border-radius: 0.5rem; + padding: 1.5rem; + margin-top: 1.5rem; + background: var(--bg-secondary); +} + +.advanced-config-section h3 { + color: var(--text-primary); + font-size: 1.25rem; + font-weight: 600; + margin-bottom: 1.5rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: center; + gap: 0.5rem; +} + +.advanced-config-section h3 i { + color: var(--primary-color); +} + +.pool-section .form-text { + margin-top: 0.5rem; + color: var(--text-secondary); + font-size: 0.75rem; + font-style: italic; +} + +.config-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + margin-bottom: 1.5rem; +} + +.config-row:last-child { + margin-bottom: 0; +} + + +/* 响应式调整 */ +@media (max-width: 768px) { + .oauth-refresh-toggle { + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + } + .restart-required-modal .restart-modal-content { + max-width: 95%; + } +} + +/* 暗黑主题适配 */ +[data-theme="dark"] .config-panel { + background: var(--bg-primary); +} + +[data-theme="dark"] .restart-required-modal .restart-modal-content { + border-color: var(--primary-color); +} + +[data-theme="dark"] .restart-notice { + background: linear-gradient(135deg, var(--success-bg) 0%, var(--success-bg-light) 100%); + border-color: var(--secondary-color); +} + +[data-theme="dark"] .restart-notice p { + color: var(--success-text); +} + +[data-theme="dark"] .restart-instructions { + background: var(--bg-secondary); + border-color: var(--border-color); +} diff --git a/static/components/section-config.html b/static/components/section-config.html new file mode 100644 index 0000000..6bfbf6a --- /dev/null +++ b/static/components/section-config.html @@ -0,0 +1,234 @@ + + +
+

配置管理

+
+
+
+ +
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ +
+ + + + + + + + +
+ 勾选启动时初始化的模型提供商 (必须至少勾选一个) +
+ + +
+

高级配置

+ + +
+

代理设置

+
+ + + 支持 HTTP、HTTPS 和 SOCKS5 代理,留空则不使用代理 +
+
+ +
+ + + + + + + + +
+ 选择需要通过代理访问的提供商,未选中的提供商将直接连接 +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + + 使用默认路径配置需添加一个空节点 +
+ +
+ + + 提供商连续错误达到此次数后将被标记为不健康,默认为 3 次 +
+ +
+ + + 当某一 Provider Type 所有账号都不健康时,自动切换到配置的 Fallback 类型。JSON 格式,键为主类型,值为 Fallback 类型数组(按优先级排序) +
+ +
+ + + 当主 Provider 不可用时,根据模型名映射到其他协议的 Provider 和模型。JSON 格式。 +
+ + +
+ + +
+ + +
+ +
+ + +
+ 用于保护管理控制台的访问,修改后需要重新登录 +
+ +
+ + +
+
+
+
\ No newline at end of file diff --git a/static/components/section-dashboard.css b/static/components/section-dashboard.css new file mode 100644 index 0000000..0bd25ce --- /dev/null +++ b/static/components/section-dashboard.css @@ -0,0 +1,416 @@ +/* 统计卡片 */ +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 1.5rem; + margin-bottom: 2rem; +} + +.stat-card { + background: var(--bg-primary); + padding: 1.5rem; + border-radius: var(--radius-xl); + border: 1px solid var(--border-color); + box-shadow: var(--shadow-sm); + display: flex; + align-items: center; + gap: 1.25rem; + transition: var(--transition); + position: relative; + overflow: hidden; +} + +.stat-card:hover { + transform: translateY(-4px); + box-shadow: var(--shadow-lg); + border-color: var(--primary-30); +} + +.stat-icon { + width: 56px; + height: 56px; + border-radius: var(--radius-lg); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.5rem; + color: var(--primary-color); + background: var(--primary-10); + flex-shrink: 0; +} + +.stat-info h3 { + font-size: 2rem; + font-weight: 700; + margin-bottom: 0.25rem; +} + +.stat-info p { + color: var(--text-secondary); + font-size: 0.875rem; +} + +/* System Info Panel in Dashboard */ +.system-info-panel { + background: var(--bg-primary); + padding: 1.5rem; + border-radius: 0.5rem; + box-shadow: var(--shadow-md); + margin-top: 1.5rem; +} + +.system-info-panel h3 { + font-size: 1.25rem; + font-weight: 600; + color: var(--text-primary); + margin: 0; +} + +.system-info-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; +} + +.update-controls { + display: flex; + gap: 0.75rem; + align-items: center; +} + +.update-badge { + display: inline-flex; + align-items: center; + gap: 4px; + background: var(--warning-bg); + color: var(--warning-text); + padding: 2px 8px; + border-radius: 9999px; + font-size: 11px; + font-weight: 600; + margin-left: 8px; + border: 1px solid var(--warning-bg-light); + animation: bounce-in 0.5s ease-out; +} + +@keyframes bounce-in { + 0% { transform: scale(0.8); opacity: 0; } + 70% { transform: scale(1.1); } + 100% { transform: scale(1); opacity: 1; } +} + +.info-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 1rem; +} + +.info-item { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.info-item .info-label { + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--text-secondary); + font-size: 0.875rem; + font-weight: 500; +} + +.info-item .info-label i { + color: var(--primary-color); + width: 16px; + text-align: center; +} + +.info-item .info-value { + color: var(--text-primary); + font-size: 1rem; + font-weight: 600; +} + +.version-display-wrapper { + display: flex; + align-items: center; + padding-left: 1.5rem; + flex-wrap: wrap; + gap: 0.5rem; +} + +.status-healthy { + background: var(--success-bg); + color: var(--success-text); +} + +.status-unhealthy { + background: var(--danger-bg); + color: var(--danger-text); +} + +/* Dashboard Top Row Layout */ +.dashboard-top-row { + display: flex; + gap: 1.5rem; + margin-bottom: 2rem; + align-items: stretch; + justify-content: flex-start; +} + +.dashboard-top-row .stats-grid { + flex: 1; + margin-bottom: 0; + display: flex; + flex-direction: column; +} + +.dashboard-top-row .stats-grid .stat-card { + flex: 1; + height: 100%; +} + +.dashboard-top-row .dashboard-contact { + flex: 1; + margin-top: 0; + padding-top: 0; + border-top: none; +} + +.dashboard-top-row .dashboard-contact .contact-grid { + margin-top: 0; + height: 100%; + grid-template-columns: repeat(2, 1fr); + gap: 1rem; +} + +.dashboard-top-row .dashboard-contact .contact-card { + padding: 1.25rem; + height: 100%; + display: flex; + flex-direction: column; + justify-content: center; +} + +.dashboard-top-row .dashboard-contact .qr-container { + margin: 0.75rem 0; +} + +.dashboard-top-row .dashboard-contact .qr-code { + width: 100px; + height: 100px; +} + +.dashboard-top-row .dashboard-contact .contact-card h3 { + font-size: 1rem; +} + +.dashboard-top-row .dashboard-contact .qr-description { + font-size: 0.75rem; +} + +/* Contact and Sponsor Section */ +.contact-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 2rem; + margin-top: 1.5rem; +} + +.contact-card { + background: var(--bg-primary); + padding: 2rem; + border-radius: 0.5rem; + box-shadow: var(--shadow-md); + text-align: center; + transition: var(--transition); + border: 1px solid var(--border-color); +} + +.contact-card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-lg); + border-color: var(--primary-color); +} + +.contact-card h3 { + font-size: 1.25rem; + font-weight: 600; + margin-bottom: 1rem; + color: var(--text-primary); + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; +} + +.contact-card h3 i { + color: var(--primary-color); +} + +.qr-container { + margin: 1.5rem 0; + display: flex; + justify-content: center; +} + +.qr-code { + width: 200px; + height: 200px; + object-fit: contain; + border: 4px solid var(--border-color); + border-radius: 0.5rem; + padding: 0.5rem; + background: white; + transition: var(--transition); +} + +.qr-code:hover { + border-color: var(--primary-color); + transform: scale(1.05); +} + +.clickable-qr { + cursor: zoom-in; +} + +/* Image Zoom Overlay */ +.image-zoom-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: var(--neutral-shadow-85); + display: none; + justify-content: center; + align-items: center; + z-index: 2000; + cursor: zoom-out; + opacity: 0; + transition: opacity 0.3s ease; +} + +.image-zoom-overlay.show { + display: flex; + opacity: 1; +} + +.image-zoom-overlay img { + max-width: 90%; + max-height: 90%; + border-radius: 0.5rem; + box-shadow: 0 0 20px var(--neutral-shadow-50); + transform: scale(0.9); + transition: transform 0.3s ease; +} + +.image-zoom-overlay.show img { + transform: scale(1); +} + +.qr-description { + font-size: 0.875rem; + color: var(--text-secondary); + margin: 0; + line-height: 1.5; +} + +/* Contact section styling for dashboard */ +.contact-section { + margin-top: 2rem; + padding-top: 1.5rem; + border-top: 1px solid var(--border-color); +} + +.contact-section h3 { + font-size: 1.25rem; + font-weight: 600; + margin-bottom: 1.5rem; + color: var(--text-primary); + display: flex; + align-items: center; + gap: 0.5rem; +} + +.contact-section h3 i { + color: var(--primary-color); +} + +/* 响应式调整 */ +@media (max-width: 1024px) { + .dashboard-top-row { + flex-direction: column; + } + + .dashboard-top-row .stats-grid { + flex: none; + } + + .dashboard-top-row .dashboard-contact .qr-code { + width: 160px; + height: 160px; + } +} + +@media (max-width: 768px) { + .stats-grid { + grid-template-columns: 1fr; + } + .contact-grid { + grid-template-columns: 1fr; + gap: 1.5rem; + } + .contact-card { + padding: 1.5rem; + } + .qr-code { + width: 180px; + height: 180px; + } +} + +@media (max-width: 480px) { + .qr-code { + width: 150px; + height: 150px; + } + .contact-card { + padding: 1rem; + } +} + +/* 暗黑主题适配 */ +[data-theme="dark"] .stat-card { + background: var(--bg-primary); +} + +[data-theme="dark"] .status-healthy { + background: var(--success-bg); + color: var(--success-text); +} + +[data-theme="dark"] .status-unhealthy { + background: var(--danger-bg); + color: var(--danger-text); +} + +[data-theme="dark"] .update-badge { + background: var(--warning-bg); + color: var(--warning-text); + border-color: var(--warning-border); +} + +[data-theme="dark"] .contact-card { + background: var(--bg-primary); +} + +[data-theme="dark"] .qr-code { + background: white; +} + +[data-theme="dark"] .image-zoom-overlay { + background: var(--neutral-shadow-95); +} diff --git a/static/components/section-dashboard.html b/static/components/section-dashboard.html new file mode 100644 index 0000000..18fde1a --- /dev/null +++ b/static/components/section-dashboard.html @@ -0,0 +1,512 @@ + + +
+

系统概览

+
+
+
+
+ +
+
+

--

+

运行时间

+
+
+
+ + +
+
+
+

扫码进群,注明来意

+
+ 微信二维码 +
+

添加微信获取更多技术支持和交流

+
+ +
+
+
+ + +
+
+

系统信息

+
+ + +
+
+
+
+ + 版本号 + +
+ -- + +
+
+
+ + Node.js版本 + +
+ -- +
+
+
+ + 服务器时间 + +
+ -- +
+
+
+ + 操作系统 + +
+ -- +
+
+
+ + 内存使用 + +
+ -- +
+
+
+ + CPU 使用 + +
+ -- +
+
+
+ + 运行模式 + +
+ -- +
+
+
+ + 进程 PID + +
+ -- +
+
+
+
+ + +
+

路径路由调用示例

+

通过不同路径路由访问不同的AI模型提供商,支持灵活的模型切换

+ +
+
+
+ +

Gemini CLI OAuth

+ 突破限制 +
+
+ +
+ + +
+ + +
+
+ + /gemini-cli-oauth/v1/chat/completions +
+
+ +
curl http://localhost:3000/gemini-cli-oauth/v1/chat/completions \
+  -H "Content-Type: application/json" \
+  -H "Authorization: Bearer YOUR_API_KEY" \
+  -d '{
+    "model": "gemini-2.0-flash-exp",
+    "messages": [{"role": "user", "content": "Hello!"}],
+    "max_tokens": 1000
+  }'
+
+
+ + +
+
+ + /gemini-cli-oauth/v1/messages +
+
+ +
curl http://localhost:3000/gemini-cli-oauth/v1/messages \
+  -H "Content-Type: application/json" \
+  -H "X-API-Key: YOUR_API_KEY" \
+  -d '{
+    "model": "gemini-2.0-flash-exp",
+    "max_tokens": 1000,
+    "messages": [{"role": "user", "content": "Hello!"}]
+  }'
+
+
+
+
+ +
+
+ +

Gemini Antigravity

+ 突破限制/实验性 +
+
+ +
+ + +
+ + +
+
+ + /gemini-antigravity/v1/chat/completions +
+
+ +
curl http://localhost:3000/gemini-antigravity/v1/chat/completions \
+  -H "Content-Type: application/json" \
+  -H "Authorization: Bearer YOUR_API_KEY" \
+  -d '{
+    "model": "gemini-3-pro-preview",
+    "messages": [{"role": "user", "content": "Hello!"}],
+    "max_tokens": 1000
+  }'
+
+
+ + +
+
+ + /gemini-antigravity/v1/messages +
+
+ +
curl http://localhost:3000/gemini-antigravity/v1/messages \
+  -H "Content-Type: application/json" \
+  -H "X-API-Key: YOUR_API_KEY" \
+  -d '{
+    "model": "gemini-3-pro-preview",
+    "max_tokens": 1000,
+    "messages": [{"role": "user", "content": "Hello!"}]
+  }'
+
+
+
+
+ +
+
+ +

Claude Custom

+ 官方API/三方 +
+
+ +
+ + +
+ + +
+
+ + /claude-custom/v1/chat/completions +
+
+ +
curl http://localhost:3000/claude-custom/v1/chat/completions \
+  -H "Content-Type: application/json" \
+  -H "Authorization: Bearer YOUR_API_KEY" \
+  -d '{
+    "model": "claude-3-sonnet-20240229",
+    "messages": [{"role": "user", "content": "Hello!"}],
+    "max_tokens": 1000
+  }'
+
+
+ + +
+
+ + /claude-custom/v1/messages +
+
+ +
curl http://localhost:3000/claude-custom/v1/messages \
+  -H "Content-Type: application/json" \
+  -H "X-API-Key: YOUR_API_KEY" \
+  -d '{
+    "model": "claude-3-sonnet-20240229",
+    "max_tokens": 1000,
+    "messages": [{"role": "user", "content": "Hello!"}]
+  }'
+
+
+
+
+ +
+
+ +

Claude Kiro OAuth

+ 突破限制/免费使用 +
+
+ +
+ + +
+ + +
+
+ + /claude-kiro-oauth/v1/chat/completions +
+
+ +
curl http://localhost:3000/claude-kiro-oauth/v1/chat/completions \
+  -H "Content-Type: application/json" \
+  -H "Authorization: Bearer YOUR_API_KEY" \
+  -d '{
+    "model": "claude-3-5-sonnet-20241022",
+    "messages": [{"role": "user", "content": "Hello!"}],
+    "max_tokens": 1000
+  }'
+
+
+ + +
+
+ + /claude-kiro-oauth/v1/messages +
+
+ +
curl http://localhost:3000/claude-kiro-oauth/v1/messages \
+  -H "Content-Type: application/json" \
+  -H "X-API-Key: YOUR_API_KEY" \
+  -d '{
+    "model": "claude-3-5-sonnet-20241022",
+    "max_tokens": 1000,
+    "messages": [{"role": "user", "content": "Hello!"}]
+  }'
+
+
+
+
+ +
+
+ +

OpenAI Custom

+ 官方API/三方 +
+
+ +
+ + +
+ + +
+
+ + /openai-custom/v1/chat/completions +
+
+ +
curl http://localhost:3000/openai-custom/v1/chat/completions \
+  -H "Content-Type: application/json" \
+  -H "Authorization: Bearer YOUR_API_KEY" \
+  -d '{
+    "model": "gpt-4",
+    "messages": [{"role": "user", "content": "Hello!"}],
+    "max_tokens": 1000
+  }'
+
+
+ + +
+
+ + /openai-custom/v1/messages +
+
+ +
curl http://localhost:3000/openai-custom/v1/messages \
+  -H "Content-Type: application/json" \
+  -H "X-API-Key: YOUR_API_KEY" \
+  -d '{
+    "model": "gpt-4",
+    "max_tokens": 1000,
+    "messages": [{"role": "user", "content": "Hello!"}]
+  }'
+
+
+
+
+ +
+
+ +

Qwen OAuth

+ 突破限制 +
+
+ +
+ + +
+ + +
+
+ + /openai-qwen-oauth/v1/chat/completions +
+
+ +
curl http://localhost:3000/openai-qwen-oauth/v1/chat/completions \
+  -H "Content-Type: application/json" \
+  -H "Authorization: Bearer YOUR_API_KEY" \
+  -d '{
+    "model": "qwen-turbo",
+    "messages": [{"role": "user", "content": "Hello!"}],
+    "max_tokens": 1000
+  }'
+
+
+ + +
+
+ + /openai-qwen-oauth/v1/messages +
+
+ +
curl http://localhost:3000/openai-qwen-oauth/v1/messages \
+  -H "Content-Type: application/json" \
+  -H "X-API-Key: YOUR_API_KEY" \
+  -d '{
+    "model": "qwen-turbo",
+    "max_tokens": 1000,
+    "messages": [{"role": "user", "content": "Hello!"}]
+  }'
+
+
+
+
+ +
+
+ +

iFlow OAuth

+ 突破限制 +
+
+ +
+ + +
+ + +
+
+ + /openai-iflow/v1/chat/completions +
+
+ +
curl http://localhost:3000/openai-iflow/v1/chat/completions \
+  -H "Content-Type: application/json" \
+  -H "Authorization: Bearer YOUR_API_KEY" \
+  -d '{
+    "model": "qwen3-max",
+    "messages": [{"role": "user", "content": "Hello!"}],
+    "max_tokens": 1000
+  }'
+
+
+ + +
+
+ + /openai-iflow/v1/messages +
+
+ +
curl http://localhost:3000/openai-iflow/v1/messages \
+  -H "Content-Type: application/json" \
+  -H "X-API-Key: YOUR_API_KEY" \
+  -d '{
+    "model": "qwen3-max",
+    "max_tokens": 1000,
+    "messages": [{"role": "user", "content": "Hello!"}]
+  }'
+
+
+
+
+ +
+ +
+

使用提示

+
    +
  • 即时切换: 通过修改URL路径即可切换不同的AI模型提供商
  • +
  • 客户端配置: 在Cherry-Studio、NextChat、Cline等客户端中设置API端点为对应路径
  • +
  • 跨协议调用: 支持OpenAI协议调用Claude模型,或Claude协议调用OpenAI模型
  • +
+
+
+ +
\ No newline at end of file diff --git a/static/components/section-logs.css b/static/components/section-logs.css new file mode 100644 index 0000000..aa33825 --- /dev/null +++ b/static/components/section-logs.css @@ -0,0 +1,89 @@ +/* 日志 */ +.logs-controls { + display: flex; + gap: 1rem; + margin-bottom: 1rem; +} + +.logs-container { + background: var(--code-bg); + color: var(--code-text); + padding: 1.5rem; + border-radius: 0.5rem; + height: 800px; + overflow-y: auto; + font-family: 'Courier New', monospace; + font-size: 0.875rem; + box-shadow: var(--shadow-md); +} + +.log-entry { + margin-bottom: 0.5rem; + padding: 0.25rem 0; +} + +.log-time { + color: var(--log-time); +} + +.log-level-info { + color: var(--log-info); +} + +.log-level-error { + color: var(--log-error); +} + +.log-level-warn { + color: var(--log-warn); +} + +/* 系统信息卡片样式 */ +.system-info { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 1.5rem; +} + +.info-card { + background: var(--bg-primary); + padding: 1.5rem; + border-radius: 0.5rem; + box-shadow: var(--shadow-md); +} + +.info-card h3 { + font-size: 1.125rem; + font-weight: 600; + margin-bottom: 1rem; + color: var(--text-primary); +} + +.info-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 0; + border-bottom: 1px solid var(--border-color); +} + +.info-row:last-child { + border-bottom: none; +} + +.info-label { + color: var(--text-secondary); + font-size: 0.875rem; +} + +.info-value { + color: var(--text-primary); + font-weight: 500; + font-size: 0.875rem; +} + +/* 暗黑主题适配 */ +[data-theme="dark"] .logs-container { + background: var(--code-bg); + color: var(--code-text); +} diff --git a/static/components/section-logs.html b/static/components/section-logs.html new file mode 100644 index 0000000..f9b2de1 --- /dev/null +++ b/static/components/section-logs.html @@ -0,0 +1,16 @@ + + +
+

实时日志

+
+ + +
+
+ +
+
\ No newline at end of file diff --git a/static/components/section-plugins.css b/static/components/section-plugins.css new file mode 100644 index 0000000..7924e4a --- /dev/null +++ b/static/components/section-plugins.css @@ -0,0 +1,151 @@ +/* 插件管理样式 */ +.plugins-panel { + background: var(--bg-primary); + padding: 2rem; + border-radius: 0.5rem; + box-shadow: var(--shadow-md); +} + +.plugins-description { + margin-bottom: 2rem; +} + +.plugins-stats { + margin-bottom: 2rem; +} + +.plugins-controls { + display: flex; + justify-content: flex-end; + margin-bottom: 1.5rem; +} + +.plugins-list-container { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 0.5rem; + overflow: hidden; +} + +.plugins-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 1.5rem; + padding: 1.5rem; +} + +.plugin-card { + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 0.5rem; + padding: 1.5rem; + transition: var(--transition); + display: flex; + flex-direction: column; + gap: 1rem; +} + +.plugin-card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-md); + border-color: var(--primary-color); +} + +.plugin-header { + display: flex; + justify-content: space-between; + align-items: flex-start; +} + +.plugin-title h3 { + margin: 0; + font-size: 1.125rem; + font-weight: 600; + color: var(--text-primary); +} + +.plugin-version { + font-size: 0.75rem; + color: var(--text-secondary); + background: var(--bg-tertiary); + padding: 0.125rem 0.5rem; + border-radius: 9999px; + margin-top: 0.25rem; + display: inline-block; +} + +.plugin-description { + font-size: 0.875rem; + color: var(--text-secondary); + line-height: 1.5; + flex: 1; +} + +.plugin-badges { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.plugin-badge { + font-size: 0.75rem; + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.plugin-badge.middleware { background: var(--info-bg); color: var(--info-text); } +.plugin-badge.routes { background: var(--success-bg); color: var(--success-text); } +.plugin-badge.hooks { background: var(--warning-bg); color: var(--warning-text); } + +.plugin-status { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + font-weight: 500; + padding-top: 1rem; + border-top: 1px solid var(--border-color); +} + +.plugin-card.enabled .plugin-status { + color: var(--success-color); +} + +.plugin-card.disabled .plugin-status { + color: var(--text-secondary); +} + +.plugin-card.disabled { + opacity: 0.8; + background: var(--bg-secondary); +} + +.plugins-loading, +.plugins-empty { + text-align: center; + padding: 3rem; + color: var(--text-secondary); +} + +.plugins-empty { + display: none; + flex-direction: column; + align-items: center; + gap: 1rem; +} + +.plugins-empty i { + font-size: 3rem; + opacity: 0.5; +} + +/* 暗黑主题适配 */ +[data-theme="dark"] .plugins-list-container { background: var(--bg-secondary); } +[data-theme="dark"] .plugin-card { background: var(--bg-primary); } +[data-theme="dark"] .plugin-card.disabled { background: var(--bg-tertiary); } +[data-theme="dark"] .plugin-badge.middleware { background: var(--info-bg); color: var(--info-text); } +[data-theme="dark"] .plugin-badge.routes { background: var(--success-bg); color: var(--success-text); } +[data-theme="dark"] .plugin-badge.hooks { background: var(--warning-bg); color: var(--warning-text); } diff --git a/static/components/section-plugins.html b/static/components/section-plugins.html new file mode 100644 index 0000000..e9a0423 --- /dev/null +++ b/static/components/section-plugins.html @@ -0,0 +1,67 @@ + + +
+

插件管理

+
+
+
+ + 插件系统允许您扩展系统功能,启用或禁用插件需要重启服务才能生效 +
+
+ + +
+
+
+ +
+
+

0

+

总插件数

+
+
+
+
+ +
+
+

0

+

已启用

+
+
+
+
+ +
+
+

0

+

已禁用

+
+
+
+ + +
+ +
+ + + + + +
+
+ +
+
+ +

暂无已安装的插件

+
+
+
+
\ No newline at end of file diff --git a/static/components/section-providers.css b/static/components/section-providers.css new file mode 100644 index 0000000..2e133ae --- /dev/null +++ b/static/components/section-providers.css @@ -0,0 +1,1419 @@ +/* 统计卡片 */ +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 1.5rem; + margin-bottom: 2rem; +} + +.stat-card { + background: var(--bg-primary); + padding: 1.5rem; + border-radius: var(--radius-xl); + border: 1px solid var(--border-color); + box-shadow: var(--shadow-sm); + display: flex; + align-items: center; + gap: 1.25rem; + transition: var(--transition); + position: relative; + overflow: hidden; +} + +.stat-card:hover { + transform: translateY(-4px); + box-shadow: var(--shadow-lg); + border-color: var(--primary-30); +} + +.stat-icon { + width: 56px; + height: 56px; + border-radius: var(--radius-lg); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.5rem; + color: var(--primary-color); + background: var(--primary-10); + flex-shrink: 0; +} + +.stat-info h3 { + font-size: 2rem; + font-weight: 700; + margin-bottom: 0.25rem; +} + +.stat-info p { + color: var(--text-secondary); + font-size: 0.875rem; +} + +/* 提供商列表 */ +.providers-container { + background: var(--bg-primary); + padding: 1.5rem; + border-radius: var(--radius-xl); + box-shadow: var(--shadow-sm); + border: 1px solid var(--border-color); +} + +.providers-list { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.provider-item { + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + padding: 1.5rem; + transition: var(--transition); + background: var(--bg-secondary); +} + +.provider-item:hover { + border-color: var(--primary-color); + box-shadow: var(--shadow-md); + background: var(--bg-primary); +} + +.provider-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.provider-name { + font-size: 1.125rem; + font-weight: 600; + color: var(--text-primary); +} + +.provider-header-right { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.provider-status { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.25rem 0.75rem; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 500; +} + +/* Path Routing Examples Panel */ +.routing-examples-panel { + background: var(--bg-primary); + padding: 1.5rem; + border-radius: 0.5rem; + box-shadow: var(--shadow-md); + margin-top: 1.5rem; + margin-bottom: 1.5rem; +} + +.routing-examples-panel h3 { + font-size: 1.25rem; + font-weight: 600; + margin-bottom: 0.5rem; + color: var(--text-primary); + display: flex; + align-items: center; + gap: 0.5rem; +} + +.routing-examples-panel h3 i { + color: var(--primary-color); +} + +.routing-description { + color: var(--text-secondary); + font-size: 0.875rem; + margin-bottom: 1.5rem; + line-height: 1.5; +} + +.routing-examples-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); + gap: 1.5rem; + margin-bottom: 2rem; +} + +.routing-example-card { + border: 1px solid var(--border-color); + border-radius: 0.5rem; + overflow: hidden; + transition: var(--transition); + background: var(--bg-secondary); +} + +.routing-example-card:hover { + border-color: var(--primary-color); + box-shadow: var(--shadow-md); + transform: translateY(-2px); +} + +.routing-card-header { + background: var(--bg-primary); + padding: 1rem 1.5rem; + display: flex; + align-items: center; + gap: 0.75rem; + border-bottom: 1px solid var(--border-color); +} + +.routing-card-header i { + font-size: 1.25rem; + color: var(--primary-color); +} + +.routing-card-header h4 { + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); + margin: 0; + flex: 1; +} + +.provider-badge { + padding: 0.25rem 0.5rem; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.provider-badge.official { background: var(--info-bg); color: var(--info-text); } +.provider-badge.oauth { background: var(--success-bg); color: var(--success-text); } +.provider-badge.responses { background: var(--warning-bg); color: var(--warning-text); } + +.routing-card-content { + padding: 1.5rem; +} + +/* 协议标签样式 */ +.protocol-tabs { + display: flex; + margin-bottom: 1rem; + border-bottom: 1px solid var(--border-color); +} + +.protocol-tab { + background: none; + border: none; + padding: 0.75rem 1rem; + cursor: pointer; + font-size: 0.875rem; + font-weight: 500; + color: var(--text-secondary); + border-bottom: 2px solid transparent; + transition: var(--transition); + position: relative; +} + +.protocol-tab:hover { + color: var(--primary-color); + background: var(--bg-tertiary); +} + +.protocol-tab.active { + color: var(--primary-color); + border-bottom-color: var(--primary-color); + background: var(--bg-secondary); +} + +/* 协议内容区域 */ +.protocol-content { + display: none; + animation: fadeIn 0.3s ease; +} + +.provider-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 1rem; + margin-top: 1rem; +} + +.provider-stat { + display: flex; + flex-direction: column; +} + +.provider-stat-label { + font-size: 0.75rem; + color: var(--text-secondary); + margin-bottom: 0.25rem; +} + +.provider-stat-value { + font-size: 1.125rem; + font-weight: 600; + color: var(--text-primary); +} + +.protocol-content.active { + display: block; +} + +.endpoint-info { + margin-bottom: 1rem; +} + +.endpoint-info label { + display: block; + font-size: 0.875rem; + font-weight: 500; + color: var(--text-secondary); + margin-bottom: 0.5rem; +} + +.endpoint-path { + display: inline-block; + background: var(--bg-primary); + padding: 0.5rem 0.75rem; + border-radius: 0.375rem; + font-family: 'Courier New', monospace; + font-size: 0.875rem; + color: var(--text-primary); + border: 1px solid var(--border-color); + position: relative; + padding-right: 2.5rem; +} + +.copy-btn { + position: absolute; + right: 0.5rem; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 0.25rem; + border-radius: 0.25rem; + transition: var(--transition); +} + +.copy-btn:hover { + background: var(--bg-tertiary); + color: var(--primary-color); +} + +.usage-example { + margin-top: 1rem; +} + +.usage-example label { + display: block; + font-size: 0.875rem; + font-weight: 500; + color: var(--text-secondary); + margin-bottom: 0.5rem; +} + +.usage-example pre { + background: var(--code-bg); + color: var(--code-text); + padding: 1rem; + border-radius: 0.375rem; + overflow-x: auto; + font-size: 0.75rem; + line-height: 1.4; + margin: 0; +} + +.usage-example code { + font-family: 'Courier New', monospace; + white-space: pre-wrap; +} + +.routing-tips { + background: var(--bg-secondary); + padding: 1.5rem; + border-radius: 0.5rem; + border-left: 4px solid var(--primary-color); +} + +.routing-tips h4 { + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 1rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.routing-tips h4 i { + color: var(--warning-color); +} + +.routing-tips ul { + margin: 0; + padding-left: 1.5rem; +} + +.routing-tips li { + margin-bottom: 0.75rem; + color: var(--text-secondary); + font-size: 0.875rem; + line-height: 1.5; +} + +.routing-tips code { + background: var(--bg-primary); + padding: 0.125rem 0.25rem; + border-radius: 0.25rem; + font-family: 'Courier New', monospace; + font-size: 0.75rem; + color: var(--primary-color); +} + +/* 提供商类型显示 */ +.provider-type-text { + font-size: 16px; + font-weight: 600; + color: var(--secondary-color); + cursor: pointer; + padding: 4px 8px; + border-radius: 4px; + transition: all 0.3s ease; +} + +.provider-type-text:hover { + background: var(--bg-tertiary); + color: var(--primary-color); +} + +/* 模态框样式 */ +.provider-modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; + animation: fadeIn 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} + +.provider-modal-content { + background: var(--bg-primary); + border-radius: var(--radius-xl); + width: 95%; + max-width: 1200px; + max-height: 85vh; + overflow: hidden; + box-shadow: var(--shadow-xl); + animation: slideIn 0.3s cubic-bezier(0.16, 1, 0.3, 1); + border: 1px solid var(--border-color); +} + +.provider-modal-header { + padding: 1.5rem; + border-bottom: 1px solid var(--border-color); + display: flex; + justify-content: space-between; + align-items: center; + background: var(--bg-secondary); +} + +.provider-modal-header h3 { + margin: 0; + color: var(--neutral-700); + font-size: 20px; + font-weight: 600; +} + +.modal-close { + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: var(--neutral-500); + padding: 0.5rem; + border-radius: 50%; + transition: var(--transition); + width: 2rem; + height: 2rem; + display: flex; + align-items: center; + justify-content: center; + line-height: 1; +} + +.modal-close:hover { + background: var(--neutral-200); + color: var(--neutral-600); + transform: rotate(90deg); +} + +.modal-cancel { + padding: 0.75rem 1.5rem; + background: var(--bg-tertiary); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: 0.375rem; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: var(--transition); +} + +.modal-cancel:hover { + background: var(--neutral-200); + border-color: var(--neutral-500); + transform: translateY(-1px); +} + +/* 授权信息样式 */ +.auth-info { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.auth-info p { + margin: 0; + font-size: 0.875rem; + color: var(--text-secondary); +} + +.auth-info strong { + color: var(--text-primary); + font-weight: 600; +} + +.auth-url-container { + position: relative; + display: flex; + align-items: center; +} + +.auth-url-container .copy-btn { + position: absolute; + right: 0.5rem; + top: 50%; + transform: translateY(-50%); + background: var(--primary-color); + color: white; + border: none; + padding: 0.5rem; + border-radius: 0.25rem; + cursor: pointer; + transition: var(--transition); +} + +.auth-url-container .copy-btn:hover { + background: var(--btn-primary-hover); +} + +.provider-modal-body { + padding: 24px; + max-height: calc(85vh - 80px); + overflow-y: auto; +} + +.provider-summary { + display: flex; + gap: 24px; + align-items: center; + margin-bottom: 24px; + padding: 20px; + background: linear-gradient(135deg, var(--neutral-100) 0%, var(--bg-primary) 100%); + border-radius: 12px; + border: 1px solid var(--neutral-200); +} + +.provider-summary-item { + display: flex; + flex-direction: column; + align-items: center; + padding: 12px; +} + +.provider-summary-item .label { + font-size: 13px; + color: var(--neutral-500); + margin-bottom: 8px; + font-weight: 500; +} + +.provider-summary-item .value { + font-size: 24px; + font-weight: bold; + color: var(--neutral-700); +} + +.provider-summary-actions { + margin-left: auto; + display: flex; + align-items: center; + gap: 0.75rem; +} + +.provider-actions { + margin-bottom: 24px; +} + +.provider-item-detail { + border: 1px solid var(--neutral-200); + border-radius: 12px; + margin-bottom: 16px; + overflow: hidden; + box-shadow: 0 2px 8px var(--neutral-shadow-md); + transition: all 0.3s ease; + position: relative; +} + +.provider-item-detail:hover { + box-shadow: 0 4px 16px var(--neutral-shadow-lg); + transform: translateY(-1px); +} + +.provider-item-header { + padding: 20px; + background: linear-gradient(135deg, var(--neutral-100) 0%, var(--bg-primary) 100%); + display: flex; + justify-content: space-between; + align-items: center; + cursor: pointer; + transition: all 0.3s ease; +} + +.provider-item-header:hover { + background: linear-gradient(135deg, var(--neutral-200) 0%, var(--neutral-100) 100%); +} + +.provider-info { + flex: 1; +} + +.provider-name { + font-weight: 600; + margin-bottom: 8px; + color: var(--neutral-700); + font-size: 15px; +} + +.provider-meta { + font-size: 13px; + color: var(--neutral-500); + line-height: 1.4; +} + +.provider-health-meta { + font-size: 12px; + color: var(--neutral-800); + margin-top: 4px; + line-height: 1.4; +} + +.provider-health-meta i { + margin-right: 4px; + opacity: 0.7; +} + +.provider-error-info { + margin-top: 8px; + padding: 8px 12px; + background: linear-gradient(135deg, var(--danger-bg-alt) 0%, var(--danger-bg-medium) 100%); + border: 1px solid var(--danger-border-light); + border-radius: 6px; + font-size: 12px; + display: flex; + align-items: flex-start; + gap: 8px; +} + +.provider-error-info i { + color: var(--danger-icon); + flex-shrink: 0; + margin-top: 2px; +} + +.provider-error-info .error-label { + color: var(--danger-label); + font-weight: 600; + white-space: nowrap; +} + +.provider-error-info .error-message { + color: var(--danger-text-dark); + word-break: break-word; + max-height: 60px; + overflow-y: auto; +} + +.provider-actions-group { + display: flex; + gap: 8px; + align-items: center; +} + +.btn-edit { + background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); + color: var(--white); + box-shadow: 0 2px 8px var(--primary-30); +} + +.btn-edit:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px var(--primary-40); +} + +.btn-delete { + background: linear-gradient(135deg, var(--danger-alt) 0%, var(--danger-secondary) 100%); + color: var(--white); + box-shadow: 0 2px 8px var(--danger-30); +} + +.btn-delete:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px var(--danger-40); +} + +.btn-quick-link { + background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); + color: white; + border: none; + border-radius: 4px; + padding: 4px 8px; + font-size: 11px; + cursor: pointer; + margin-left: 8px; + display: inline-flex; + align-items: center; + gap: 4px; + transition: all 0.2s ease; + box-shadow: 0 2px 6px var(--indigo-30); +} + +.btn-quick-link:hover { + transform: translateY(-1px); + box-shadow: var(--shadow-md); + background: linear-gradient(135deg, var(--btn-primary-hover) 0%, var(--primary-color) 100%); +} + +.btn-batch-link { + background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); + color: white; + border: none; + border-radius: 6px; + padding: 6px 12px; + font-size: 12px; + cursor: pointer; + margin-left: 12px; + display: inline-flex; + align-items: center; + gap: 6px; + transition: all 0.2s ease; + box-shadow: 0 2px 8px var(--indigo-30); + font-weight: 500; +} + +.btn-batch-link:hover { + transform: translateY(-1px); + box-shadow: var(--shadow-md); + background: linear-gradient(135deg, var(--btn-primary-hover) 0%, var(--primary-color) 100%); +} + +.btn-save { + background: linear-gradient(135deg, var(--btn-success) 0%, var(--btn-success-secondary) 100%); + color: var(--white); + box-shadow: 0 2px 8px var(--danger-30); +} + +.btn-cancel { + background: linear-gradient(135deg, var(--neutral-500) 0%, var(--neutral-600) 100%); + color: var(--white); + box-shadow: 0 2px 8px var(--primary-30); +} + +.provider-item-content { + padding: 20px; + display: none; + border-top: 1px solid var(--neutral-200); + background: var(--bg-primary); +} + +.provider-item-content.expanded { + display: block; +} + +.config-item { + display: flex; + flex-direction: column; +} + +.config-item label { + font-size: 13px; + color: var(--neutral-600); + margin-bottom: 8px; + font-weight: 500; +} + +.config-item input, .config-item textarea, .config-item select { + padding: 12px; + border: 2px solid var(--neutral-200); + border-radius: 8px; + font-size: 13px; + transition: all 0.3s ease; +} + +.config-item input:focus, .config-item textarea:focus, .config-item select:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 3px var(--primary-10); +} + +.config-item input[readonly], .config-item select[disabled] { + background: var(--neutral-100); + color: var(--neutral-500); +} + +/* 模态框中的文件上传输入框样式 */ +.config-item .file-input-group { + position: relative; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.config-item .file-input-group input { + flex: 1; + padding-right: 0.75rem; + box-sizing: border-box; +} + +/* 模态框中的密码输入框样式 */ +.config-item .password-input-wrapper { + position: relative; + width: 100%; +} + +.config-item .password-input-wrapper input { + width: 100%; + padding-right: 2.5rem; + box-sizing: border-box; +} + +.config-item .password-toggle { + position: absolute; + right: 0.5rem; + padding: 0.25rem; + background: none; + border: none; + cursor: pointer; + color: var(--text-secondary); + transition: var(--transition); + width: auto; + height: auto; + line-height: 1; +} + +.add-provider-form { + background: linear-gradient(135deg, var(--neutral-100) 0%, var(--bg-primary) 100%); + padding: 24px; + border-radius: 12px; + margin-bottom: 24px; + border: 1px solid var(--neutral-200); +} + +.add-provider-form h4 { + margin: 0 0 20px 0; + color: var(--neutral-700); + font-size: 18px; + font-weight: 600; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 20px; + margin-bottom: 20px; +} + +.form-grid.full-width { + grid-column: 1 / -1; +} + +/* 无提供商提示 */ +.no-providers { + text-align: center; + padding: 2rem; + color: var(--text-secondary); +} + +/* 健康状态高亮样式 */ +.provider-item-detail.unhealthy { + border: 2px solid var(--warning-color); + background: linear-gradient(135deg, var(--warning-bg) 0%, var(--bg-primary) 100%); + box-shadow: 0 4px 12px var(--warning-15); + animation: pulseWarning 2s infinite; +} + +.provider-item-detail.unhealthy:hover { + border-color: var(--warning-color); + box-shadow: 0 6px 20px var(--warning-25); + transform: translateY(-2px); +} + +.provider-item-detail.unhealthy .provider-item-header { + background: linear-gradient(135deg, var(--warning-bg) 0%, var(--bg-primary) 100%); + border-bottom: 1px solid var(--warning-20); +} + +.provider-item-detail.healthy { + border: 1px solid var(--neutral-200); + background: linear-gradient(135deg, var(--neutral-100) 0%, var(--bg-primary) 100%); +} + +.provider-item-detail.healthy:hover { + border-color: var(--primary-color); + box-shadow: 0 4px 16px var(--neutral-shadow-lg); +} + +.health-status { + display: inline-flex; + align-items: center; + gap: 0.5rem; + font-weight: 500; + padding: 0.25rem 0.5rem; + border-radius: 6px; + transition: var(--transition); +} + +.provider-item-detail.unhealthy .health-status { + color: var(--warning-text); + background: var(--warning-15); +} + +.provider-item-detail.healthy .health-status { + color: var(--success-text); + background: var(--success-10); +} + +@keyframes pulseWarning { + 0%, 100% { box-shadow: 0 4px 12px var(--warning-15); } + 50% { box-shadow: 0 4px 12px var(--warning-30); } +} + +.provider-item-detail.unhealthy::before { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 4px; + background: linear-gradient(135deg, var(--warning-color) 0%, var(--warning-color) 100%); + border-radius: 0 2px 2px 0; + z-index: 1; +} + +/* 禁用提供商状态样式 */ +.provider-item-detail.disabled { + opacity: 0.6; + background: linear-gradient(135deg, var(--neutral-100) 0%, var(--bg-primary) 100%); + border: 1px solid var(--neutral-300); + position: relative; +} + +.provider-item-detail.disabled::before { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 4px; + background: linear-gradient(135deg, var(--neutral-500) 0%, var(--neutral-600) 100%); + border-radius: 0 2px 2px 0; + z-index: 1; +} + +.provider-item-detail.disabled:hover { + opacity: 0.8; + border-color: var(--neutral-400); + box-shadow: 0 2px 8px var(--neutral-shadow-lg); + transform: none; +} + +.provider-item-detail.disabled .provider-item-header { + background: linear-gradient(135deg, var(--neutral-200) 0%, var(--bg-primary) 100%); +} + +.provider-item-detail.disabled .provider-name { + color: var(--neutral-500); + text-decoration: line-through; +} + +.disabled-status { + display: inline-flex; + align-items: center; + gap: 0.5rem; + font-weight: 500; + padding: 0.25rem 0.5rem; + border-radius: 6px; + transition: var(--transition); +} + +.provider-item-detail.disabled .disabled-status { + color: var(--neutral-500); + background: var(--primary-10); +} + +.provider-item-detail:not(.disabled) .disabled-status { + color: var(--primary-color); + background: var(--success-10); +} + +/* 禁用/启用按钮特殊样式 */ +.btn-warning { + background: linear-gradient(135deg, var(--warning-color) 0%, var(--warning-text-dark) 100%); + color: var(--white); + box-shadow: 0 2px 8px var(--warning-30); +} + +.btn-warning:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px var(--warning-40); +} + +/* 健康检测按钮样式 */ +.btn-info { + background: linear-gradient(135deg, var(--info-color) 0%, var(--info-color-dark) 100%); + color: var(--white); +} + +.btn-info:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px var(--info-hover); +} + +/* 不支持的模型选择器样式 */ +.not-supported-models-section { + grid-column: 1 / -1; + margin-top: 16px; +} + +.not-supported-models-section label { + display: flex; + align-items: center; + gap: 8px; + font-weight: 600; + color: var(--neutral-700); + margin-bottom: 12px; +} + +.not-supported-models-section .help-text { + font-size: 12px; + font-weight: normal; + color: var(--neutral-500); + margin-left: 4px; +} + +.not-supported-models-container { + background: var(--neutral-100); + border: 1px solid var(--neutral-300); + border-radius: 8px; + padding: 16px; + min-height: 100px; +} + +.models-loading { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + color: var(--neutral-500); + padding: 20px; +} + +.models-checkbox-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 12px; +} + +.model-checkbox-label { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: var(--white); + border: 1px solid var(--neutral-300); + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; +} + +.model-checkbox-label:hover { + background: var(--neutral-200); + border-color: var(--neutral-400); +} + +.model-checkbox-label .model-name { + font-size: 13px; + color: var(--neutral-600); + user-select: none; +} + +.model-checkbox-label input[type="checkbox"]:checked + .model-name { + color: var(--danger-alt); + font-weight: 500; +} + +/* 授权按钮样式 */ +.generate-auth-btn { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.25rem 0.75rem; + background: var(--info-bg-light); + color: var(--info-text-dark); + border: none; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 500; + cursor: pointer; + transition: var(--transition); +} + +.generate-auth-btn:hover { + background: var(--info-hover); + color: var(--info-text-darker); + transform: translateY(-1px); +} + +/* 授权模态框样式 */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: var(--overlay-bg); + backdrop-filter: blur(4px); + display: none; + justify-content: center; + align-items: center; + z-index: 1000; + animation: fadeIn 0.3s ease; +} + +.modal-content { + background: var(--bg-primary); + border-radius: 0.75rem; + width: 90%; + max-width: 600px; + max-height: 85vh; + overflow: hidden; + box-shadow: 0 25px 80px var(--neutral-shadow-40); + display: flex; + flex-direction: column; + animation: modalSlideIn 0.3s ease; +} + +.modal-header { + padding: 1.5rem 2rem; + border-bottom: 1px solid var(--border-color); + display: flex; + justify-content: space-between; + align-items: center; + background: linear-gradient(135deg, var(--info-bg-alt) 0%, var(--white) 100%); +} + +.modal-header h3 { + margin: 0; + font-size: 1.25rem; + font-weight: 600; + color: var(--text-primary); + display: flex; + align-items: center; + gap: 0.5rem; +} + +.modal-body { + padding: 2rem; + flex: 1; + overflow-y: auto; +} + +.modal-footer { + padding: 1.5rem 2rem; + border-top: 1px solid var(--border-color); + display: flex; + justify-content: flex-end; + gap: 1rem; + background: linear-gradient(135deg, var(--bg-secondary) 0%, var(--white) 100%); +} + +.open-auth-btn { + padding: 0.75rem 1.5rem; + background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); + color: white; + border: none; + border-radius: 0.375rem; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: var(--transition); + box-shadow: 0 2px 8px var(--primary-30); + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +.open-auth-btn:hover { + background: linear-gradient(135deg, var(--btn-primary-hover) 0%, var(--primary-color) 100%); + transform: translateY(-2px); + box-shadow: 0 4px 12px var(--primary-40); +} + +.auth-instructions { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 0.5rem; + padding: 1.5rem; + border-left: 4px solid var(--primary-color); +} + +.auth-instructions h4 { margin: 0 0 1rem 0; font-size: 1rem; font-weight: 600; } +.auth-instructions ol { margin: 0 0 1rem 1.5rem; padding: 0; } +.auth-instructions li { margin-bottom: 0.5rem; font-size: 0.875rem; line-height: 1.5; } + +.auth-url-input { + flex: 1; + padding: 0.75rem; + padding-right: 3rem; + border: 1px solid var(--border-color); + border-radius: 0.375rem; + font-size: 0.875rem; + font-family: 'Courier New', monospace; + background: var(--bg-tertiary); + color: var(--text-primary); +} + +.auth-url-input:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 3px var(--primary-10); +} + +/* 高亮说明样式 */ +.highlight-note { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 1rem; + background: linear-gradient(135deg, var(--warning-bg) 0%, var(--warning-bg-light) 100%); + border: 1px solid var(--warning-border); + border-radius: 0.5rem; + margin-bottom: 1.5rem; + color: var(--warning-text); + font-weight: 500; + width: 100%; + box-sizing: border-box; +} + +.highlight-note i { + color: var(--warning-color); + font-size: 1.25rem; + flex-shrink: 0; +} + +.highlight-note span { + flex: 1; + text-align: center; +} + + +.form-group { + margin-bottom: 1.5rem; +} + +.form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.5rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + font-weight: 600; + color: var(--text-primary); + font-size: 0.9rem; +} + +.optional-tag, .form-group label .optional-mark { + font-size: 0.75rem; + color: var(--text-tertiary); + font-weight: 400; + margin-left: 0.5rem; + background: var(--bg-tertiary); + padding: 0.125rem 0.375rem; + border-radius: var(--radius-sm); +} + +.form-control { + width: 100%; + padding: 0.75rem 1rem; + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + font-size: 0.9rem; + transition: var(--transition); + background: var(--bg-secondary); + color: var(--text-primary); +} + +.form-control:focus { + outline: none; + border-color: var(--primary-color); + background: var(--bg-primary); + box-shadow: 0 0 0 4px var(--primary-10); +} + +.form-control::placeholder { + color: var(--text-tertiary); +} + + +/* 复选框样式 */ +.form-group input[type="checkbox"] { + width: 1rem; + height: 1rem; + accent-color: var(--primary-color); + cursor: pointer; +} + +.form-group { + display: flex; + flex-direction: column; +} + +.form-group label { + font-size: 13px; + color: var(--neutral-600); + margin-bottom: 8px; + font-weight: 500; +} + +.form-group input, .form-group select { + padding: 12px; + border: 2px solid var(--neutral-200); + border-radius: 8px; + font-size: 14px; + transition: all 0.3s ease; +} + +.form-group input:focus, .form-group select:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 3px var(--primary-10); +} + +/* 文件上传输入框样式 */ +.file-input-group { + position: relative; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.file-input-group .form-control { + flex: 1; + padding-right: 0.75rem; +} + +.file-input-group .btn-outline { + height: 38px; + width: 38px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + border-radius: 6px; + flex-shrink: 0; + background: var(--bg-tertiary); + color: var(--text-secondary); + border: 1px solid var(--border-color); +} + +.file-input-group .btn-outline:hover { + background: var(--bg-secondary); + color: var(--primary-color); + border-color: var(--primary-color); +} + +/* 模态框中的文件上传输入框样式 */ +.config-item .file-input-group { + position: relative; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.config-item .file-input-group input { + flex: 1; + padding-right: 0.75rem; + box-sizing: border-box; +} + +/* 响应式调整 */ +@media (max-width: 768px) { + .routing-examples-grid { grid-template-columns: 1fr; } + .provider-modal-content { width: 98%; max-height: 95vh; } + .provider-summary { flex-direction: column; align-items: flex-start; gap: 16px; } + .provider-summary-actions { margin-left: 0; } + .form-grid { grid-template-columns: 1fr; } + .provider-item-detail.disabled { opacity: 0.5; } + .disabled-status { font-size: 0.75rem; padding: 0.2rem 0.4rem; } + .provider-actions-group { flex-wrap: wrap; gap: 0.5rem; } + .modal-content { width: 95%; max-height: 90vh; } + .stats-grid { grid-template-columns: 1fr; } +} + +/* 暗黑主题适配 */ +[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-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, var(--warning-bg) 0%, var(--bg-primary) 100%); } +[data-theme="dark"] .provider-error-info { background: linear-gradient(135deg, var(--danger-bg) 0%, var(--danger-bg-medium) 100%); border-color: var(--danger-border); } +[data-theme="dark"] .routing-tips { background: var(--bg-secondary); border-left-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"] .modal-content { background: var(--bg-primary); } +[data-theme="dark"] .modal-header, [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-url-input { background: var(--bg-tertiary); border-color: var(--border-color); color: var(--text-primary); } +[data-theme="dark"] .model-checkbox-label { background: var(--bg-primary); border-color: var(--border-color); } +[data-theme="dark"] .not-supported-models-container { background: var(--bg-secondary); border-color: var(--border-color); } +[data-theme="dark"] .stat-card { transition: background-color 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease; } +/* 高亮说明样式 - 暗黑主题 */ +[data-theme="dark"] .highlight-note { + background: linear-gradient(135deg, var(--warning-bg) 0%, var(--warning-bg-light) 100%); + border-color: var(--warning-border); + color: var(--warning-text); +} + +[data-theme="dark"] .highlight-note i { + color: var(--warning-color); +} \ No newline at end of file diff --git a/static/components/section-providers.html b/static/components/section-providers.html new file mode 100644 index 0000000..93b481a --- /dev/null +++ b/static/components/section-providers.html @@ -0,0 +1,46 @@ + + +
+

提供商池管理

+
+
+ + 使用默认路径配置需添加一个空节点 +
+
+ +
+
+
+ +
+
+

0

+

活动连接

+
+
+
+
+ +
+
+

0

+

活跃提供商

+
+
+
+
+ +
+
+

0

+

健康提供商

+
+
+
+
+
+ +
+
+
\ No newline at end of file diff --git a/static/components/section-upload-config.css b/static/components/section-upload-config.css new file mode 100644 index 0000000..86da830 --- /dev/null +++ b/static/components/section-upload-config.css @@ -0,0 +1,408 @@ +/* 配置管理页面样式 */ +.upload-config-panel { + background: var(--bg-primary); + padding: 2rem; + border-radius: 0.5rem; + box-shadow: var(--shadow-md); +} + +.config-search-panel { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 0.5rem; + padding: 1.5rem; + margin-bottom: 2rem; +} + +.search-controls { + display: grid; + grid-template-columns: 2fr 1fr auto; + gap: 1rem; + align-items: end; +} + +.search-input-group { + position: relative; + display: flex; + align-items: center; +} + +.search-input-group .form-control { + flex: 1; + padding-right: 3rem; +} + +.search-input-group .btn { + position: absolute; + right: 0.5rem; + padding: 0.5rem 0.75rem; + background: var(--primary-color); + color: white; + border: none; + border-radius: 0.375rem; + cursor: pointer; + transition: var(--transition); +} + +.search-input-group .btn:hover { + background: var(--btn-primary-hover); + transform: translateY(-1px); +} + +.config-list-container { + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 0.5rem; + overflow: hidden; +} + +.config-list-header { + background: var(--bg-tertiary); + padding: 1rem 1.5rem; + border-bottom: 1px solid var(--border-color); + display: flex; + justify-content: space-between; + align-items: center; +} + +.config-list-header h3 { + margin: 0; + font-size: 1.125rem; + font-weight: 600; + color: var(--text-primary); +} + +.config-stats { + display: flex; + gap: 1rem; + align-items: center; +} + +.config-stats span { + font-size: 0.875rem; + font-weight: 500; + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; +} + +.status-used { background: var(--success-bg); color: var(--success-text); } +.status-unused { background: var(--warning-bg); color: var(--warning-text); } +.status-invalid { background: var(--danger-bg); color: var(--danger-text); } + +.config-list { + overflow-y: auto; +} + +.config-item-manager { + padding: 1.5rem; + border-bottom: 1px solid var(--border-color); + transition: var(--transition); + cursor: pointer; +} + +.config-item-manager:hover { + background: var(--bg-secondary); +} + +.config-item-manager:last-child { + border-bottom: none; +} + +.config-item-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.75rem; +} + +.config-item-name { + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); + flex: 1; +} + +.config-item-path { + font-size: 0.75rem; + color: var(--text-secondary); + font-family: 'Courier New', monospace; + background: var(--bg-tertiary); + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + margin: 0 0.5rem; + max-width: 300px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.config-item-meta { + display: flex; + gap: 1rem; + align-items: center; + font-size: 0.875rem; + color: var(--text-secondary); +} + +.config-item-type { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.5rem; + background: var(--bg-tertiary); + border-radius: 0.25rem; + font-size: 0.75rem; + font-weight: 500; +} + +.config-item-status { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + font-size: 0.75rem; + font-weight: 500; +} + +.config-item-manager.used .config-item-status { background: var(--success-bg); color: var(--success-text); } +.config-item-manager.unused .config-item-status { background: var(--warning-bg); color: var(--warning-text); } +.config-item-manager.invalid .config-item-status { background: var(--danger-bg); color: var(--danger-text); } + +.config-item-details { + margin-top: 0.75rem; + padding-top: 0.75rem; + border-top: 1px solid var(--border-color); + display: none; +} + +.config-item-manager.expanded .config-item-details { + display: block; +} + +.config-details-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; + margin-bottom: 1rem; +} + +.config-detail-item { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.config-detail-label { + font-size: 0.75rem; + color: var(--text-secondary); + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.config-detail-value { + font-size: 0.875rem; + color: var(--text-primary); + font-family: 'Courier New', monospace; + word-break: break-all; +} + +.config-item-actions { + display: flex; + gap: 0.5rem; + margin-top: 0.75rem; + padding-top: 0.75rem; + border-top: 1px solid var(--border-color); +} + +.btn-view { background: var(--primary-color); color: white; } +.btn-view:hover { background: var(--btn-primary-hover); } +.btn-delete-small { background: var(--danger-color); color: white; } + +.config-item-manager.expanded { + background: var(--bg-secondary); +} + +/* 配置查看模态框样式 */ +.config-view-modal { + position: fixed; + top: 0; left: 0; width: 100%; height: 100%; + background: var(--overlay-bg); + backdrop-filter: blur(4px); + display: flex; justify-content: center; align-items: center; + z-index: 1000; opacity: 0; visibility: hidden; + transition: all 0.3s ease; +} + +.config-view-modal.show { opacity: 1; visibility: visible; } + +.config-modal-content { + background: var(--bg-primary); + border-radius: 0.5rem; + width: 90%; max-width: 800px; max-height: 80vh; + overflow: hidden; + box-shadow: 0 20px 60px var(--neutral-shadow-30); + display: flex; flex-direction: column; + animation: modalSlideIn 0.3s ease; +} + +.config-modal-header { + padding: 1.5rem; + border-bottom: 1px solid var(--border-color); + display: flex; justify-content: space-between; align-items: center; + background: var(--bg-secondary); +} + +.config-modal-body { + padding: 1.5rem; flex: 1; overflow-y: auto; +} + +.config-file-info { + display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; margin-bottom: 1.5rem; padding: 1rem; + background: var(--bg-secondary); border-radius: 0.5rem; +} + +.config-content-display { + background: var(--code-bg); color: var(--code-text); + padding: 1rem; border-radius: 0.5rem; + font-family: 'Courier New', monospace; font-size: 0.875rem; + line-height: 1.5; max-height: 400px; overflow-y: auto; + white-space: pre-wrap; word-wrap: break-word; +} + +.config-modal-footer { + padding: 1.5rem; border-top: 1px solid var(--border-color); + display: flex; justify-content: flex-end; gap: 1rem; + background: var(--bg-secondary); +} + +/* 关联信息显示样式 */ +.config-usage-info { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 0.5rem; + margin: 1rem 0; + padding: 1rem; + border-left: 4px solid var(--primary-color); +} + +.usage-info-header { + display: flex; align-items: center; gap: 0.5rem; + margin-bottom: 1rem; padding-bottom: 0.75rem; + border-bottom: 1px solid var(--border-color); +} + +.usage-details-list { + display: flex; flex-direction: column; gap: 0.75rem; +} + +.usage-detail-item { + display: flex; align-items: center; gap: 0.75rem; + padding: 0.75rem; background: var(--bg-primary); + border: 1px solid var(--border-color); border-radius: 0.375rem; +} + +.usage-detail-type { + font-size: 0.75rem; font-weight: 600; background: var(--primary-color); + color: var(--white); padding: 0.125rem 0.5rem; border-radius: 0.25rem; +} + +.usage-detail-location { + font-size: 0.875rem; color: var(--text-secondary); + font-family: 'Courier New', monospace; word-break: break-all; flex: 1; +} + +/* 删除确认模态框样式 */ +.delete-confirm-modal { + position: fixed; top: 0; left: 0; width: 100%; height: 100%; + background: var(--overlay-bg); backdrop-filter: blur(4px); + display: flex; justify-content: center; align-items: center; + z-index: 1000; opacity: 0; visibility: hidden; + transition: all 0.3s ease; +} + +.delete-confirm-modal.show { opacity: 1; visibility: visible; } + +.delete-modal-content { + background: var(--bg-primary); border-radius: 0.75rem; + width: 90%; max-width: 600px; max-height: 85vh; + overflow: hidden; box-shadow: 0 25px 80px var(--neutral-shadow-40); + display: flex; flex-direction: column; + animation: modalSlideIn 0.3s ease; border: 2px solid transparent; +} + +.delete-confirm-modal.used .delete-modal-content { border-color: var(--danger-color); box-shadow: 0 25px 80px var(--danger-30); } +.delete-confirm-modal.unused .delete-modal-content { border-color: var(--warning-color); box-shadow: 0 25px 80px var(--warning-20); } + +.delete-modal-header { + padding: 1.5rem 2rem; border-bottom: 1px solid var(--border-color); + display: flex; justify-content: space-between; align-items: center; + background: var(--bg-secondary); +} + +.delete-modal-body { + padding: 2rem; flex: 1; overflow-y: auto; max-height: calc(85vh - 160px); +} + +.delete-warning { + display: flex; align-items: flex-start; gap: 1rem; + padding: 1.5rem; border-radius: 0.5rem; margin-bottom: 1.5rem; border: 2px solid; +} + +.delete-warning.warning-used { background: linear-gradient(135deg, var(--danger-bg-alt) 0%, var(--bg-primary) 100%); border-color: var(--danger-border-light); color: var(--danger-color); } +.delete-warning.warning-unused { background: linear-gradient(135deg, var(--warning-bg-alt) 0%, var(--bg-primary) 100%); border-color: var(--warning-border-light); color: var(--warning-text-dark); } + +.config-info { + background: var(--bg-secondary); border: 1px solid var(--border-color); + border-radius: 0.5rem; padding: 1.5rem; margin-bottom: 1.5rem; +} + +.config-info-item { + display: flex; justify-content: space-between; align-items: center; + padding: 0.5rem 0; border-bottom: 1px solid var(--border-color); +} + +.usage-alert { + display: flex; align-items: flex-start; gap: 1rem; padding: 1.5rem; + background: linear-gradient(135deg, var(--danger-bg) 0%, var(--danger-bg-alt) 100%); + border: 1px solid var(--danger-border-light); border-radius: 0.5rem; margin-top: 1rem; +} + +.delete-modal-footer { + padding: 1.5rem 2rem; border-top: 1px solid var(--border-color); + display: flex; justify-content: flex-end; gap: 1rem; background: var(--bg-secondary); +} + +.btn-confirm-delete { position: relative; overflow: hidden; } +.delete-confirm-modal.used .btn-confirm-delete { background: linear-gradient(135deg, var(--warning-color) 0%, var(--warning-text-dark) 100%); color: var(--white); box-shadow: 0 4px 15px var(--warning-40); animation: pulseDanger 2s infinite; } + +@keyframes pulseDanger { + 0%, 100% { box-shadow: 0 4px 15px var(--danger-40); } + 50% { box-shadow: 0 4px 15px var(--danger-70); } +} + +/* 响应式设计 */ +@media (max-width: 768px) { + .search-controls { grid-template-columns: 1fr; gap: 1rem; } + .config-list-header { flex-direction: column; align-items: flex-start; gap: 0.5rem; } + .config-item-header { flex-direction: column; align-items: flex-start; gap: 0.5rem; } + .config-item-path { max-width: 100%; margin: 0; } + .config-modal-content { width: 95%; max-height: 90vh; } + .config-file-info { grid-template-columns: 1fr; } + .delete-modal-content { width: 95%; max-height: 90vh; } + .config-info-item { flex-direction: column; align-items: flex-start; gap: 0.25rem; } + .delete-modal-footer { flex-direction: column; } +} + +/* 暗黑主题适配 */ +[data-theme="dark"] .status-used { background: var(--success-bg); color: var(--success-text); } +[data-theme="dark"] .status-unused { background: var(--warning-bg); color: var(--warning-text); } +[data-theme="dark"] .status-invalid { background: var(--danger-bg); color: var(--danger-text); } +[data-theme="dark"] .config-modal-content { background: var(--bg-primary); } +[data-theme="dark"] .config-modal-header, [data-theme="dark"] .config-modal-footer { background: linear-gradient(135deg, var(--bg-secondary) 0%, var(--bg-primary) 100%); } +[data-theme="dark"] .delete-modal-content { background: var(--bg-primary); } +[data-theme="dark"] .delete-confirm-modal.used .delete-modal-header { background: linear-gradient(135deg, var(--danger-bg) 0%, var(--bg-primary) 100%); } +[data-theme="dark"] .delete-confirm-modal.unused .delete-modal-header { background: linear-gradient(135deg, var(--warning-bg) 0%, var(--bg-primary) 100%); } +[data-theme="dark"] .config-info { background: var(--bg-secondary); border-color: var(--border-color); } diff --git a/static/components/section-upload-config.html b/static/components/section-upload-config.html new file mode 100644 index 0000000..faea4b3 --- /dev/null +++ b/static/components/section-upload-config.html @@ -0,0 +1,58 @@ + + +
+

配置管理

+
+ +
+
+
+ +
+ + +
+
+
+ + +
+
+ +
+ + +
+
+
+
+ + +
+
+

配置文件列表

+
+ 共 0 个配置文件 + 已关联: 0 + 未关联: 0 + +
+
+
+ +
+
+
+
\ No newline at end of file diff --git a/static/components/section-usage.css b/static/components/section-usage.css new file mode 100644 index 0000000..72e88b4 --- /dev/null +++ b/static/components/section-usage.css @@ -0,0 +1,369 @@ +/* 用量查询页面样式 */ +.usage-panel { + background: var(--bg-primary); + border-radius: 0.5rem; + box-shadow: var(--shadow-md); + padding: 1.5rem; +} + +.usage-controls { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--border-color); +} + +.usage-last-update { + font-size: 0.875rem; + color: var(--text-secondary); +} + +.usage-loading, .usage-error, .usage-empty { + text-align: center; + padding: 3rem; + color: var(--text-secondary); +} + +.usage-error { + color: var(--danger-color); + background: var(--danger-bg-light); + border-radius: 0.5rem; + border: 1px solid var(--danger-bg); +} + +/* 提供商分组样式 */ +.usage-provider-group { + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 0.5rem; + margin-bottom: 1rem; + overflow: hidden; +} + +.usage-group-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1rem; + background: var(--bg-secondary); + user-select: none; + transition: var(--transition); +} + +.usage-group-header:hover { + background: var(--bg-tertiary); +} + +.usage-group-title { + display: flex; + 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); +} + +.usage-group-title .toggle-icon { + font-size: 0.75rem; + color: var(--text-secondary); + transition: transform 0.3s ease; + width: 1rem; +} + +.usage-provider-group:not(.collapsed) .toggle-icon { + transform: rotate(90deg); +} + +.usage-group-title .provider-icon { + font-size: 1.25rem; + color: var(--primary-color); +} + +.usage-group-title .provider-name { + font-weight: 600; + font-size: 1rem; + color: var(--text-primary); +} + +.usage-group-title .instance-count { + font-size: 0.875rem; + color: var(--text-secondary); + background: var(--bg-tertiary); + padding: 0.25rem 0.5rem; + border-radius: 9999px; +} + +.usage-group-title .success-count { + font-size: 0.75rem; + color: var(--text-secondary); + margin-left: auto; +} + +.usage-group-title .success-count.all-success { + color: var(--success-color); +} + +.usage-group-content { + padding: 1rem; + display: block; +} + +.usage-provider-group.collapsed .usage-group-content { + display: none; +} + +/* 用量卡片网格 */ +.usage-cards-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 1rem; +} + +.stat-success { color: var(--success-color); } +.stat-error { color: var(--danger-color); } +.stat-total { color: var(--text-secondary); } + +/* 实例卡片 */ +.usage-instance-card { + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 0.5rem; + overflow: hidden; + transition: var(--transition); + display: flex; + flex-direction: column; +} + +.usage-instance-card:hover { + box-shadow: var(--shadow-md); + transform: translateY(-2px); +} + +.usage-instance-card.error { border-color: var(--danger-border); } + +.usage-instance-header { + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--border-color); + background: linear-gradient(to right, var(--bg-tertiary), var(--bg-secondary)); +} + +.instance-header-top { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.375rem; +} + +.instance-provider-type { + display: flex; + align-items: center; + gap: 0.375rem; + font-size: 0.7rem; + color: var(--primary-color); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.instance-name-text { + font-size: 0.875rem; + font-weight: 600; + color: var(--text-primary); + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.instance-user-info { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + padding-top: 0.375rem; + border-top: 1px dashed var(--border-color); + margin-top: 0.375rem; +} + +.user-email { + font-size: 0.7rem; color: var(--text-secondary); + display: flex; align-items: center; gap: 0.25rem; + max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} + +.user-subscription { + font-size: 0.65rem; padding: 0.125rem 0.375rem; + background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); + color: white; border-radius: 9999px; font-weight: 500; +} + +.badge { + font-size: 0.75rem; padding: 0.125rem 0.5rem; border-radius: 9999px; font-weight: 500; +} + +.badge-healthy { background: var(--success-bg); color: var(--success-text); } +.badge-unhealthy { background: var(--danger-bg); color: var(--danger-text); } +.badge-disabled { background: var(--bg-tertiary); color: var(--text-secondary); } + +.usage-instance-content { + padding: 0.75rem; flex: 1; +} + +.usage-error-message { + display: flex; align-items: center; gap: 0.5rem; color: var(--danger-color); + padding: 0.75rem; background: var(--danger-bg-light); border-radius: 0.375rem; font-size: 0.75rem; +} + +.usage-details { + display: flex; flex-direction: column; gap: 0.75rem; +} + +.usage-section { + background: var(--bg-secondary); padding: 0.75rem; border-radius: 0.375rem; border: 1px solid var(--border-color); +} + +.usage-section h4 { + font-size: 0.75rem; font-weight: 600; color: var(--text-secondary); + margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.375rem; + text-transform: uppercase; letter-spacing: 0.05em; +} + +.usage-section .info-grid { + display: flex; flex-direction: column; gap: 0.375rem; +} + +.usage-section .info-item { + display: flex; justify-content: space-between; align-items: center; + padding-bottom: 0.375rem; border-bottom: 1px solid var(--border-color); +} + +.usage-section .label { font-size: 0.75rem; color: var(--text-secondary); } +.usage-section .value { font-size: 0.75rem; font-weight: 600; color: var(--text-primary); text-align: right; max-width: 60%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.total-usage { background: linear-gradient(135deg, var(--bg-primary) 0%, var(--bg-secondary) 100%); border: 1px solid var(--border-color); } +.total-usage-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem; } +.total-label { font-size: 0.8rem; font-weight: 600; color: var(--text-primary); display: flex; align-items: center; gap: 0.375rem; } +.total-value { font-size: 0.75rem; font-weight: 600; color: var(--text-secondary); font-family: monospace; } + +.reset-info-compact { background: var(--bg-secondary); padding: 0.5rem 0.75rem; border-radius: 0.375rem; border: 1px solid var(--border-color); } +.reset-info-row { display: flex; justify-content: space-between; align-items: center; padding: 0.25rem 0; } +.reset-info-row:first-child { border-bottom: 1px solid var(--border-color); padding-bottom: 0.375rem; margin-bottom: 0.25rem; } +.reset-label { font-size: 0.7rem; color: var(--text-secondary); display: flex; align-items: center; gap: 0.25rem; } +.reset-value { font-size: 0.7rem; font-weight: 600; color: var(--text-primary); } + +.usage-breakdown-compact { background: var(--bg-secondary); padding: 0.5rem; border-radius: 0.375rem; border: 1px solid var(--border-color); } +.breakdown-item-compact { padding: 0.5rem; background: var(--bg-primary); border-radius: 0.25rem; margin-bottom: 0.5rem; border: 1px solid var(--border-color); } + +.breakdown-header-compact { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.375rem; font-size: 0.7rem; } +.breakdown-header-compact .breakdown-usage { color: var(--text-secondary); font-family: monospace; } + +.progress-bar-small { height: 0.25rem; background: var(--bg-tertiary); border-radius: 9999px; overflow: hidden; margin-bottom: 0.375rem; } +.progress-bar-small .progress-fill { height: 100%; border-radius: 9999px; transition: width 0.3s ease; } +.progress-bar-small.normal .progress-fill { background: var(--success-color); } +.progress-bar-small.warning .progress-fill { background: var(--warning-color); } +.progress-bar-small.danger .progress-fill { background: var(--danger-color); } + +.extra-usage-info { display: flex; flex-wrap: wrap; align-items: center; gap: 0.375rem; font-size: 0.6rem; padding: 0.375rem; border-radius: 0.25rem; margin-top: 0.375rem; } +.extra-usage-info.free-trial { background: var(--info-bg-lighter); color: var(--info-text); border: 1px solid var(--info-bg); } +.extra-usage-info.bonus { background: var(--warning-bg-alt); color: var(--warning-text); border: 1px solid var(--warning-bg); } + +.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; +} + +.collapsed-summary-row { display: flex; align-items: center; gap: 0.5rem; } +.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-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); width: 100%; margin-top: 0.125rem; text-align: center; } + +.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-instance-card.collapsed .usage-card-expanded-content { display: none; } +.usage-instance-card.collapsed .usage-card-collapsed-summary { border-bottom: none; } + +/* 分页控件样式 */ +.pagination-container { + display: flex; align-items: center; justify-content: space-between; + padding: 12px 16px; background: var(--bg-tertiary); border-radius: 8px; + margin: 12px 0; flex-wrap: wrap; gap: 12px; +} + +.page-btn { + min-width: 36px; height: 36px; padding: 0 10px; border: 1px solid var(--border-color); + background: var(--bg-primary); color: var(--text-primary); border-radius: 6px; + cursor: pointer; font-size: 0.875rem; font-weight: 500; transition: var(--transition); + display: inline-flex; align-items: center; justify-content: center; +} + +.page-btn:hover:not(:disabled), .page-btn.active { background: var(--primary-color); color: white; border-color: var(--primary-color); } + +.page-jump-input { + width: 60px; height: 36px; padding: 0 8px; border: 1px solid var(--border-color); + border-radius: 6px; text-align: center; font-size: 0.875rem; + background: var(--bg-primary); color: var(--text-primary); +} + +/* 响应式调整 */ +@media (max-width: 1400px) { .usage-cards-grid { grid-template-columns: repeat(3, 1fr); } } +@media (max-width: 1024px) { .usage-cards-grid { grid-template-columns: repeat(2, 1fr); } } +@media (max-width: 768px) { + .usage-cards-grid { grid-template-columns: 1fr; } + .pagination-container { flex-direction: column; align-items: stretch; gap: 10px; } + .usage-instance-header { padding: 0.5rem 0.75rem; } + .usage-instance-content { padding: 0.5rem; } +} + +/* 暗黑主题适配 */ +[data-theme="dark"] .usage-error-message { background: var(--danger-bg); color: var(--danger-text); } +[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"] .usage-breakdown-compact { background: var(--bg-secondary); border-color: var(--border-color); } +[data-theme="dark"] .extra-usage-info.free-trial { background: var(--info-bg); color: var(--info-text); border-color: var(--info-border); } +[data-theme="dark"] .extra-usage-info.bonus { background: var(--warning-bg); color: var(--warning-text); border-color: var(--warning-border); } +[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-jump-input { background: var(--bg-primary); border-color: var(--border-color); color: var(--text-primary); } diff --git a/static/components/section-usage.html b/static/components/section-usage.html new file mode 100644 index 0000000..faed652 --- /dev/null +++ b/static/components/section-usage.html @@ -0,0 +1,30 @@ + + +
+

用量查询

+
+
+ + 上次更新: -- +
+ + + + + +
+ +
+ +

点击"刷新用量"按钮获取授权文件用量信息

+
+
+
+
\ No newline at end of file diff --git a/static/components/sidebar.css b/static/components/sidebar.css new file mode 100644 index 0000000..c622ce1 --- /dev/null +++ b/static/components/sidebar.css @@ -0,0 +1,67 @@ +/* 侧边栏 */ +.sidebar { + width: 260px; + background: var(--bg-glass); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid var(--border-color); + border-radius: var(--radius-xl); + padding: 1rem; + display: flex; + flex-direction: column; + height: fit-content; + position: sticky; + top: 5rem; +} + +.sidebar-nav { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.nav-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + color: var(--text-secondary); + text-decoration: none; + transition: var(--transition); + font-weight: 500; + cursor: pointer; + border-radius: var(--radius-lg); + user-select: none; +} + +.nav-item:hover { + background: var(--bg-tertiary); + color: var(--text-primary); +} + +.nav-item.active { + background: var(--primary-10); + color: var(--primary-color); + font-weight: 600; +} + +.nav-item i { + width: 20px; + text-align: center; + font-size: 1.1em; +} + +/* 响应式调整 */ +@media (max-width: 768px) { + .sidebar { + width: 100%; + border-right: none; + border-bottom: 1px solid var(--border-color); + } + + .sidebar-nav { + flex-direction: row; + overflow-x: auto; + padding: 0 1rem; + } +} diff --git a/static/components/sidebar.html b/static/components/sidebar.html new file mode 100644 index 0000000..16276fb --- /dev/null +++ b/static/components/sidebar.html @@ -0,0 +1,27 @@ + + + \ No newline at end of file diff --git a/static/index.html b/static/index.html index ecf1dfb..73c2a53 100644 --- a/static/index.html +++ b/static/index.html @@ -6,1035 +6,24 @@ AIClient2API - 管理控制台 - +
- -
-
-

AIClient2API 管理控制台

-
- - KIRO账号购买 - - - 连接中... - - - - - - - -
-
-
- + +
- - + + -
- -
-

系统概览

-
-
-
-
- -
-
-

--

-

运行时间

-
-
-
- - -
-
-
-

扫码进群,注明来意

-
- 微信二维码 -
-

添加微信获取更多技术支持和交流

-
- -
-
-
- - -
-
-

系统信息

-
- - -
-
-
-
- - 版本号 - -
- -- - -
-
-
- - Node.js版本 - -
- -- -
-
-
- - 服务器时间 - -
- -- -
-
-
- - 操作系统 - -
- -- -
-
-
- - 内存使用 - -
- -- -
-
-
- - CPU 使用 - -
- -- -
-
-
- - 运行模式 - -
- -- -
-
-
- - 进程 PID - -
- -- -
-
-
-
- - -
-

路径路由调用示例

-

通过不同路径路由访问不同的AI模型提供商,支持灵活的模型切换

- -
-
-
- -

Gemini CLI OAuth

- 突破限制 -
-
- -
- - -
- - -
-
- - /gemini-cli-oauth/v1/chat/completions -
-
- -
curl http://localhost:3000/gemini-cli-oauth/v1/chat/completions \
-  -H "Content-Type: application/json" \
-  -H "Authorization: Bearer YOUR_API_KEY" \
-  -d '{
-    "model": "gemini-2.0-flash-exp",
-    "messages": [{"role": "user", "content": "Hello!"}],
-    "max_tokens": 1000
-  }'
-
-
- - -
-
- - /gemini-cli-oauth/v1/messages -
-
- -
curl http://localhost:3000/gemini-cli-oauth/v1/messages \
-  -H "Content-Type: application/json" \
-  -H "X-API-Key: YOUR_API_KEY" \
-  -d '{
-    "model": "gemini-2.0-flash-exp",
-    "max_tokens": 1000,
-    "messages": [{"role": "user", "content": "Hello!"}]
-  }'
-
-
-
-
- -
-
- -

Gemini Antigravity

- 突破限制/实验性 -
-
- -
- - -
- - -
-
- - /gemini-antigravity/v1/chat/completions -
-
- -
curl http://localhost:3000/gemini-antigravity/v1/chat/completions \
-  -H "Content-Type: application/json" \
-  -H "Authorization: Bearer YOUR_API_KEY" \
-  -d '{
-    "model": "gemini-3-pro-preview",
-    "messages": [{"role": "user", "content": "Hello!"}],
-    "max_tokens": 1000
-  }'
-
-
- - -
-
- - /gemini-antigravity/v1/messages -
-
- -
curl http://localhost:3000/gemini-antigravity/v1/messages \
-  -H "Content-Type: application/json" \
-  -H "X-API-Key: YOUR_API_KEY" \
-  -d '{
-    "model": "gemini-3-pro-preview",
-    "max_tokens": 1000,
-    "messages": [{"role": "user", "content": "Hello!"}]
-  }'
-
-
-
-
- -
-
- -

Claude Custom

- 官方API/三方 -
-
- -
- - -
- - -
-
- - /claude-custom/v1/chat/completions -
-
- -
curl http://localhost:3000/claude-custom/v1/chat/completions \
-  -H "Content-Type: application/json" \
-  -H "Authorization: Bearer YOUR_API_KEY" \
-  -d '{
-    "model": "claude-3-sonnet-20240229",
-    "messages": [{"role": "user", "content": "Hello!"}],
-    "max_tokens": 1000
-  }'
-
-
- - -
-
- - /claude-custom/v1/messages -
-
- -
curl http://localhost:3000/claude-custom/v1/messages \
-  -H "Content-Type: application/json" \
-  -H "X-API-Key: YOUR_API_KEY" \
-  -d '{
-    "model": "claude-3-sonnet-20240229",
-    "max_tokens": 1000,
-    "messages": [{"role": "user", "content": "Hello!"}]
-  }'
-
-
-
-
- -
-
- -

Claude Kiro OAuth

- 突破限制/免费使用 -
-
- -
- - -
- - -
-
- - /claude-kiro-oauth/v1/chat/completions -
-
- -
curl http://localhost:3000/claude-kiro-oauth/v1/chat/completions \
-  -H "Content-Type: application/json" \
-  -H "Authorization: Bearer YOUR_API_KEY" \
-  -d '{
-    "model": "claude-3-5-sonnet-20241022",
-    "messages": [{"role": "user", "content": "Hello!"}],
-    "max_tokens": 1000
-  }'
-
-
- - -
-
- - /claude-kiro-oauth/v1/messages -
-
- -
curl http://localhost:3000/claude-kiro-oauth/v1/messages \
-  -H "Content-Type: application/json" \
-  -H "X-API-Key: YOUR_API_KEY" \
-  -d '{
-    "model": "claude-3-5-sonnet-20241022",
-    "max_tokens": 1000,
-    "messages": [{"role": "user", "content": "Hello!"}]
-  }'
-
-
-
-
- -
-
- -

OpenAI Custom

- 官方API/三方 -
-
- -
- - -
- - -
-
- - /openai-custom/v1/chat/completions -
-
- -
curl http://localhost:3000/openai-custom/v1/chat/completions \
-  -H "Content-Type: application/json" \
-  -H "Authorization: Bearer YOUR_API_KEY" \
-  -d '{
-    "model": "gpt-4",
-    "messages": [{"role": "user", "content": "Hello!"}],
-    "max_tokens": 1000
-  }'
-
-
- - -
-
- - /openai-custom/v1/messages -
-
- -
curl http://localhost:3000/openai-custom/v1/messages \
-  -H "Content-Type: application/json" \
-  -H "X-API-Key: YOUR_API_KEY" \
-  -d '{
-    "model": "gpt-4",
-    "max_tokens": 1000,
-    "messages": [{"role": "user", "content": "Hello!"}]
-  }'
-
-
-
-
- -
-
- -

Qwen OAuth

- 突破限制 -
-
- -
- - -
- - -
-
- - /openai-qwen-oauth/v1/chat/completions -
-
- -
curl http://localhost:3000/openai-qwen-oauth/v1/chat/completions \
-  -H "Content-Type: application/json" \
-  -H "Authorization: Bearer YOUR_API_KEY" \
-  -d '{
-    "model": "qwen-turbo",
-    "messages": [{"role": "user", "content": "Hello!"}],
-    "max_tokens": 1000
-  }'
-
-
- - -
-
- - /openai-qwen-oauth/v1/messages -
-
- -
curl http://localhost:3000/openai-qwen-oauth/v1/messages \
-  -H "Content-Type: application/json" \
-  -H "X-API-Key: YOUR_API_KEY" \
-  -d '{
-    "model": "qwen-turbo",
-    "max_tokens": 1000,
-    "messages": [{"role": "user", "content": "Hello!"}]
-  }'
-
-
-
-
- -
-
- -

iFlow OAuth

- 突破限制 -
-
- -
- - -
- - -
-
- - /openai-iflow/v1/chat/completions -
-
- -
curl http://localhost:3000/openai-iflow/v1/chat/completions \
-  -H "Content-Type: application/json" \
-  -H "Authorization: Bearer YOUR_API_KEY" \
-  -d '{
-    "model": "qwen3-max",
-    "messages": [{"role": "user", "content": "Hello!"}],
-    "max_tokens": 1000
-  }'
-
-
- - -
-
- - /openai-iflow/v1/messages -
-
- -
curl http://localhost:3000/openai-iflow/v1/messages \
-  -H "Content-Type: application/json" \
-  -H "X-API-Key: YOUR_API_KEY" \
-  -d '{
-    "model": "qwen3-max",
-    "max_tokens": 1000,
-    "messages": [{"role": "user", "content": "Hello!"}]
-  }'
-
-
-
-
- -
- -
-

使用提示

-
    -
  • 即时切换: 通过修改URL路径即可切换不同的AI模型提供商
  • -
  • 客户端配置: 在Cherry-Studio、NextChat、Cline等客户端中设置API端点为对应路径
  • -
  • 跨协议调用: 支持OpenAI协议调用Claude模型,或Claude协议调用OpenAI模型
  • -
-
-
- -
- - -
-

配置管理

-
-
-
- -
- - -
-
-
-
- - -
-
- - -
-
-
- -
- - - - - - - - -
- 勾选启动时初始化的模型提供商 (必须至少勾选一个) -
- - -
-

高级配置

- - -
-

代理设置

-
- - - 支持 HTTP、HTTPS 和 SOCKS5 代理,留空则不使用代理 -
-
- -
- - - - - - - - -
- 选择需要通过代理访问的提供商,未选中的提供商将直接连接 -
-
- -
-
- - -
-
- - -
-
- -
-
- - -
-
- - -
-
- -
-
- - -
-
- - -
-
- -
-
- - -
-
- - -
-
- -
- - - 使用默认路径配置需添加一个空节点 -
- -
- - - 提供商连续错误达到此次数后将被标记为不健康,默认为 3 次 -
- -
- - - 当某一 Provider Type 所有账号都不健康时,自动切换到配置的 Fallback 类型。JSON 格式,键为主类型,值为 Fallback 类型数组(按优先级排序) -
- -
- - - 当主 Provider 不可用时,根据模型名映射到其他协议的 Provider 和模型。JSON 格式。 -
- - -
- - -
- - -
- -
- - -
- 用于保护管理控制台的访问,修改后需要重新登录 -
-
- -
- - -
-
-
-
- - -
-

配置管理

-
- -
-
-
- -
- - -
-
-
- - -
-
- -
- - -
-
-
-
- - -
-
-

配置文件列表

-
- 共 0 个配置文件 - 已关联: 0 - 未关联: 0 - -
-
-
- -
-
-
-
- - -
-

提供商池管理

-
-
- - 使用默认路径配置需添加一个空节点 -
-
- -
-
-
- -
-
-

0

-

活动连接

-
-
-
-
- -
-
-

0

-

活跃提供商

-
-
-
-
- -
-
-

0

-

健康提供商

-
-
-
-
-
- -
-
-
- - -
-

用量查询

-
-
- - 上次更新: -- -
- - - - - -
- -
- -

点击"刷新用量"按钮获取授权文件用量信息

-
-
-
-
- - -
-

实时日志

-
- - -
-
- -
-
- - -
-

插件管理

-
-
-
- - 插件系统允许您扩展系统功能,启用或禁用插件需要重启服务才能生效 -
-
- - -
-
-
- -
-
-

0

-

总插件数

-
-
-
-
- -
-
-

0

-

已启用

-
-
-
-
- -
-
-

0

-

已禁用

-
-
-
- - -
- -
- - - - - -
-
- -
-
- -

暂无已安装的插件

-
-
-
-
- +
+
@@ -1048,19 +37,18 @@