feat(ui): 重构前端UI组件并添加新功能

- 新增组件加载器实现动态加载HTML组件
- 重构导航功能,添加滚动到顶部功能
- 新增多个UI组件:header、sidebar、logs、usage等
- 实现移动端菜单响应式设计
- 优化DOM元素获取方式,使用延迟加载
- 新增系统监控模块和用量缓存功能
- 扩展静态文件服务支持/components路径
- 实现插件管理和系统API接口
- 添加配置上传和管理功能
- 完善认证和token管理机制
This commit is contained in:
hex2077 2026-01-10 15:53:04 +08:00
parent bf11211a77
commit 4554a4cfd2
41 changed files with 10047 additions and 10475 deletions

View file

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

File diff suppressed because it is too large Load diff

259
src/ui-modules/auth.js Normal file
View file

@ -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分钟清理一次

View file

@ -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<Object>} 返回重载后的配置对象
*/
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;
}
}

View file

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

View file

@ -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<boolean>} 始终返回 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);
}
});
});
}

306
src/ui-modules/oauth-api.js Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<string|null>} 最新版本号或 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<Object>} 更新信息
*/
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<Object>} 更新结果
*/
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<Object>} 更新结果
*/
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;
}
}

View file

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

290
src/ui-modules/usage-api.js Normal file
View file

@ -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<Object>} 所有提供商的用量信息
*/
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<Object>} 提供商用量信息
*/
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<Object>} 用量信息
*/
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;
}
}

View file

@ -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<Object|null>} 缓存的用量数据如果不存在或读取失败则返回 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<Object|null>} 缓存的用量数据
*/
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);
}

View file

@ -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 = '<i class="fas fa-times"></i>';
console.log('Menu opened');
} else {
headerControls.style.display = 'none';
mobileMenuToggle.innerHTML = '<i class="fas fa-bars"></i>';
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 = '<i class="fas fa-bars"></i>';
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;

694
static/app/base.css Normal file
View file

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

View file

@ -0,0 +1,137 @@
/**
* 组件加载器 - 用于动态加载 HTML 组件片段
* Component Loader - For dynamically loading HTML component fragments
*/
// 组件缓存
const componentCache = new Map();
/**
* 加载单个组件
* @param {string} componentPath - 组件文件路径
* @returns {Promise<string>} - 组件 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<void>}
*/
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<void>}
*/
async function loadComponents(components) {
const promises = components.map(({ path, container, position }) =>
insertComponent(path, container, position)
);
await Promise.all(promises);
}
/**
* 初始化页面组件
* 加载所有页面组件并插入到相应位置
* @returns {Promise<void>}
*/
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
};

View file

@ -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'); },
};
// 定期刷新间隔

File diff suppressed because it is too large Load diff

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

@ -0,0 +1,31 @@
<!-- Header -->
<link rel="stylesheet" href="components/header.css">
<header class="header">
<div class="header-content">
<h1><i class="fas fa-robot"></i> <span class="header-title" data-i18n="header.title">AIClient2API 管理控制台</span></h1>
<button class="mobile-menu-toggle" id="mobileMenuToggle" aria-label="Menu" title="菜单">
<i class="fas fa-bars"></i>
</button>
<div class="header-controls" id="headerControls">
<a href="https://pay.ldxp.cn/shop/N0IK02WR" target="_blank" rel="noopener noreferrer" class="kiro-buy-link" title="KIRO账号购买">
<i class="fas fa-shopping-cart"></i> <span>KIRO账号购买</span>
</a>
<span class="status-badge" id="serverStatus">
<i class="fas fa-circle"></i> <span class="status-text" data-i18n="header.status.connecting">连接中...</span>
</span>
<a href="https://github.com/justlovemaki/AIClient-2-API" target="_blank" rel="noopener noreferrer" class="github-link" title="GitHub" data-i18n-title="header.github">
<i class="fab fa-github"></i>
</a>
<button id="themeToggleBtn" class="theme-toggle" aria-label="Toggle Theme" data-i18n-aria-label="header.themeToggle" title="切换主题" data-i18n-title="header.themeToggle">
<i class="fas fa-moon"></i>
<i class="fas fa-sun"></i>
</button>
<button id="logoutBtn" class="logout-btn" data-i18n="header.logout" title="Logout" data-i18n-title="header.logout">
<i class="fas fa-sign-out-alt"></i> <span data-i18n="header.logout">登出</span>
</button>
<button id="restartBtn" class="logout-btn" aria-label="Restart Service" data-i18n-aria-label="header.restart">
<i id="restartBtnIcon" class="fas fa-redo"></i> <span id="restartBtnText" class="btn-text" data-i18n="header.restart">重启</span>
</button>
</div>
</div>
</header>

View file

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

View file

@ -0,0 +1,234 @@
<link rel="stylesheet" href="components/section-config.css">
<!-- Configuration Section -->
<section id="config" class="section" aria-labelledby="config-title">
<h2 id="config-title" data-i18n="config.title">配置管理</h2>
<div class="config-panel">
<div class="config-form">
<div class="form-group password-input-group">
<label for="apiKey" data-i18n="config.apiKey">API密钥</label>
<div class="password-input-wrapper">
<input type="password" id="apiKey" class="form-control" data-i18n="config.apiKeyPlaceholder" placeholder="请输入API密钥" autocomplete="off">
<button type="button" class="password-toggle" data-target="apiKey" aria-label="显示/隐藏密码">
<i class="fas fa-eye" aria-hidden="true"></i>
</button>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="host" data-i18n="config.host">监听地址</label>
<input type="text" id="host" class="form-control" value="127.0.0.1">
</div>
<div class="form-group">
<label for="port" data-i18n="config.port">端口</label>
<input type="number" id="port" class="form-control" value="3000">
</div>
</div>
<div class="form-group pool-section">
<label data-i18n="config.modelProvider">模型提供商 (可多选)</label>
<div id="modelProvider" class="provider-checklist">
<label class="checkbox-item">
<input type="checkbox" value="gemini-cli-oauth">
<span>Gemini CLI OAuth</span>
</label>
<label class="checkbox-item">
<input type="checkbox" value="gemini-antigravity">
<span>Gemini Antigravity</span>
</label>
<label class="checkbox-item">
<input type="checkbox" value="openai-custom">
<span>OpenAI Custom</span>
</label>
<label class="checkbox-item">
<input type="checkbox" value="claude-custom">
<span>Claude Custom</span>
</label>
<label class="checkbox-item">
<input type="checkbox" value="claude-kiro-oauth">
<span>Claude Kiro OAuth</span>
</label>
<label class="checkbox-item">
<input type="checkbox" value="openai-qwen-oauth">
<span>Qwen OAuth</span>
</label>
<label class="checkbox-item">
<input type="checkbox" value="openaiResponses-custom">
<span>OpenAI Responses</span>
</label>
<label class="checkbox-item">
<input type="checkbox" value="openai-iflow">
<span>iFlow OAuth</span>
</label>
</div>
<small class="form-text" data-i18n="config.modelProviderHelp">勾选启动时初始化的模型提供商 (必须至少勾选一个)</small>
</div>
<!-- 高级配置区域 -->
<div class="advanced-config-section">
<h3 data-i18n="config.advanced.title"><i class="fas fa-cogs"></i> 高级配置</h3>
<!-- 代理配置 -->
<div class="proxy-config-section">
<h4 data-i18n="config.proxy.title"><i class="fas fa-globe"></i> 代理设置</h4>
<div class="form-group">
<label for="proxyUrl" data-i18n="config.proxy.url">代理地址</label>
<input type="text" id="proxyUrl" class="form-control" data-i18n-placeholder="config.proxy.urlPlaceholder" placeholder="例如: http://127.0.0.1:7890 或 socks5://127.0.0.1:1080">
<small class="form-text" data-i18n="config.proxy.urlNote">支持 HTTP、HTTPS 和 SOCKS5 代理,留空则不使用代理</small>
</div>
<div class="form-group pool-section">
<label data-i18n="config.proxy.enabledProviders">启用代理的提供商</label>
<div id="proxyProviders" class="provider-checklist">
<label class="checkbox-item">
<input type="checkbox" name="proxyProvider" value="gemini-cli-oauth">
<span>Gemini CLI OAuth</span>
</label>
<label class="checkbox-item">
<input type="checkbox" name="proxyProvider" value="gemini-antigravity">
<span>Gemini Antigravity</span>
</label>
<label class="checkbox-item">
<input type="checkbox" name="proxyProvider" value="openai-custom">
<span>OpenAI Custom</span>
</label>
<label class="checkbox-item">
<input type="checkbox" name="proxyProvider" value="claude-custom">
<span>Claude Custom</span>
</label>
<label class="checkbox-item">
<input type="checkbox" name="proxyProvider" value="claude-kiro-oauth">
<span>Claude Kiro OAuth</span>
</label>
<label class="checkbox-item">
<input type="checkbox" name="proxyProvider" value="openai-qwen-oauth">
<span>Qwen OAuth</span>
</label>
<label class="checkbox-item">
<input type="checkbox" name="proxyProvider" value="openaiResponses-custom">
<span>OpenAI Responses</span>
</label>
<label class="checkbox-item">
<input type="checkbox" name="proxyProvider" value="openai-iflow">
<span>iFlow OAuth</span>
</label>
</div>
<small class="form-text" data-i18n="config.proxy.enabledProvidersNote">选择需要通过代理访问的提供商,未选中的提供商将直接连接</small>
</div>
</div>
<div class="config-row">
<div class="form-group">
<label for="systemPromptFilePath" data-i18n="config.advanced.systemPromptFile">系统提示文件路径</label>
<input type="text" id="systemPromptFilePath" class="form-control" value="configs/input_system_prompt.txt" data-i18n-placeholder="config.advanced.systemPromptFilePlaceholder" placeholder="例如: configs/input_system_prompt.txt">
</div>
<div class="form-group">
<label for="systemPromptMode" data-i18n="config.advanced.systemPromptMode">系统提示模式</label>
<select id="systemPromptMode" class="form-control">
<option value="append" selected data-i18n="config.advanced.systemPromptMode.append">追加 (append)</option>
<option value="overwrite" data-i18n="config.advanced.systemPromptMode.overwrite">覆盖 (overwrite)</option>
</select>
</div>
</div>
<div class="config-row">
<div class="form-group">
<label for="promptLogBaseName" data-i18n="config.advanced.promptLogBaseName">提示日志基础名称</label>
<input type="text" id="promptLogBaseName" class="form-control" data-i18n-placeholder="config.advanced.promptLogBaseNamePlaceholder" placeholder="例如: prompt_log">
</div>
<div class="form-group">
<label for="promptLogMode" data-i18n="config.advanced.promptLogMode">提示日志模式</label>
<select id="promptLogMode" class="form-control">
<option value="none" data-i18n="config.advanced.promptLogMode.none">无 (none)</option>
<option value="console" data-i18n="config.advanced.promptLogMode.console">控制台 (console)</option>
<option value="file" data-i18n="config.advanced.promptLogMode.file">文件 (file)</option>
</select>
</div>
</div>
<div class="config-row">
<div class="form-group">
<label for="requestMaxRetries" data-i18n="config.advanced.maxRetries">最大重试次数</label>
<input type="number" id="requestMaxRetries" class="form-control" min="0" max="10" value="3">
</div>
<div class="form-group">
<label for="requestBaseDelay" data-i18n="config.advanced.baseDelay">重试基础延迟(毫秒)</label>
<input type="number" id="requestBaseDelay" class="form-control" min="0" step="100" value="1000">
</div>
</div>
<div class="config-row">
<div class="form-group">
<label for="cronNearMinutes" data-i18n="config.advanced.cronInterval">OAuth令牌刷新间隔(分钟)</label>
<input type="number" id="cronNearMinutes" class="form-control" min="1" max="60" value="1">
</div>
<div class="form-group">
<label for="cronNearMinutes" data-i18n="config.advanced.cronEnabled">启用OAuth令牌自动刷新(需重启服务)</label>
<label class="toggle-switch">
<input type="checkbox" id="cronRefreshToken">
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="form-group pool-section">
<label for="providerPoolsFilePath" data-i18n="config.advanced.poolFilePath">提供商池配置文件路径(不能为空)</label>
<input type="text" id="providerPoolsFilePath" class="form-control" value="" data-i18n-placeholder="config.advanced.poolFilePathPlaceholder" placeholder="默认: configs/provider_pools.json">
<small class="form-text" data-i18n="config.advanced.poolNote">使用默认路径配置需添加一个空节点</small>
</div>
<div class="form-group pool-section">
<label for="maxErrorCount" data-i18n="config.advanced.maxErrorCount">提供商最大错误次数</label>
<input type="number" id="maxErrorCount" class="form-control" value="3" min="1" max="10" data-i18n-placeholder="config.advanced.maxErrorCountPlaceholder" placeholder="默认: 3">
<small class="form-text" data-i18n="config.advanced.maxErrorCountNote">提供商连续错误达到此次数后将被标记为不健康,默认为 3 次</small>
</div>
<div class="form-group pool-section">
<label for="providerFallbackChain" data-i18n="config.advanced.fallbackChain">跨类型 Fallback 链配置</label>
<textarea id="providerFallbackChain" class="form-control" rows="6" data-i18n-placeholder="config.advanced.fallbackChainPlaceholder" placeholder='例如:
{
"gemini-cli-oauth": ["gemini-antigravity"],
"gemini-antigravity": ["gemini-cli-oauth"],
"claude-kiro-oauth": ["claude-custom"]
}'></textarea>
<small class="form-text" data-i18n="config.advanced.fallbackChainNote">当某一 Provider Type 所有账号都不健康时,自动切换到配置的 Fallback 类型。JSON 格式,键为主类型,值为 Fallback 类型数组(按优先级排序)</small>
</div>
<div class="form-group pool-section">
<label for="modelFallbackMapping" data-i18n="config.advanced.modelFallbackMapping">跨协议模型 Fallback 映射</label>
<textarea id="modelFallbackMapping" class="form-control" rows="6" data-i18n-placeholder="config.advanced.modelFallbackMappingPlaceholder" placeholder='例如:
{
"gemini-claude-opus-4-5-thinking": {
"targetProviderType": "claude-kiro-oauth",
"targetModel": "claude-opus-4-5"
}
}'></textarea>
<small class="form-text" data-i18n="config.advanced.modelFallbackMappingNote">当主 Provider 不可用时,根据模型名映射到其他协议的 Provider 和模型。JSON 格式。</small>
</div>
<!-- 系统提示配置移到最下面 -->
<div class="form-group system-prompt-section">
<label for="systemPrompt" data-i18n="config.advanced.systemPrompt">系统提示</label>
<textarea id="systemPrompt" class="form-control" rows="4" data-i18n-placeholder="config.advanced.systemPromptPlaceholder" placeholder="输入系统提示..."></textarea>
</div>
<!-- 后台登录密码配置 -->
<div class="form-group pool-section">
<label for="adminPassword" data-i18n="config.advanced.adminPassword">后台登录密码</label>
<div class="password-input-wrapper">
<input type="password" id="adminPassword" class="form-control" data-i18n-placeholder="config.advanced.adminPasswordPlaceholder" placeholder="设置后台登录密码(留空则不修改)" autocomplete="new-password">
<button type="button" class="password-toggle" data-target="adminPassword" aria-label="显示/隐藏密码" data-i18n-aria-label="common.togglePassword">
<i class="fas fa-eye" aria-hidden="true"></i>
</button>
</div>
<small class="form-text" data-i18n="config.advanced.adminPasswordNote">用于保护管理控制台的访问,修改后需要重新登录</small>
</div>
<div class="form-actions">
<button class="btn btn-success" id="saveConfig">
<i class="fas fa-save"></i> <span data-i18n="config.save">保存配置</span>
</button>
<button class="btn btn-secondary" id="resetConfig">
<i class="fas fa-undo"></i> <span data-i18n="config.reset">重置</span>
</button>
</div>
</div>
</div>
</section>

View file

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

View file

@ -0,0 +1,512 @@
<link rel="stylesheet" href="components/section-dashboard.css">
<!-- Dashboard Section -->
<section id="dashboard" class="section active" aria-labelledby="dashboard-title">
<h2 id="dashboard-title" data-i18n="dashboard.title">系统概览</h2>
<div class="dashboard-top-row">
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon">
<i class="fas fa-clock"></i>
</div>
<div class="stat-info">
<h3 id="uptime">--</h3>
<p data-i18n="dashboard.uptime">运行时间</p>
</div>
</div>
</div>
<!-- Contact and Sponsor Section -->
<div class="contact-section dashboard-contact">
<div class="contact-grid">
<div class="contact-card">
<h3><i id="wechat-icon" class="fab fa-weixin"></i> <span id="wechat-title" data-i18n="dashboard.contact.wechat">扫码进群,注明来意</span></h3>
<div class="qr-container">
<img src="static/wechat.png" id="wechat-img" alt="微信二维码" class="qr-code clickable-qr">
</div>
<p class="qr-description" id="wechat-desc" data-i18n="dashboard.contact.wechatDesc">添加微信获取更多技术支持和交流</p>
</div>
<div class="contact-card" id="sponsor-card">
<h3><i class="fas fa-heart"></i> <span id="sponsor-title" data-i18n="dashboard.contact.sponsor">扫码赞助</span></h3>
<div class="qr-container">
<img src="static/sponsor.png" id="sponsor-img" alt="赞助二维码" class="qr-code clickable-qr">
</div>
<p class="qr-description" id="sponsor-desc" data-i18n="dashboard.contact.sponsorDesc">您的赞助是项目持续发展的动力</p>
</div>
</div>
</div>
</div>
<!-- System Information Panel -->
<div class="system-info-panel">
<div class="system-info-header">
<h3 data-i18n="dashboard.systemInfo">系统信息</h3>
<div class="update-controls">
<button id="checkUpdateBtn" class="btn btn-outline btn-sm" data-i18n-title="dashboard.update.checkTitle" title="检查更新">
<i class="fas fa-sync-alt"></i> <span data-i18n="dashboard.update.check">检查更新</span>
</button>
<button id="performUpdateBtn" class="btn btn-primary btn-sm" style="display: none;" data-i18n-title="dashboard.update.performTitle" title="执行更新">
<i class="fas fa-download"></i> <span data-i18n="dashboard.update.perform">立即更新</span>
</button>
</div>
</div>
<div class="info-grid">
<div class="info-item">
<span class="info-label">
<i class="fas fa-tag"></i> <span data-i18n="dashboard.version">版本号</span>
</span>
<div class="version-display-wrapper">
<span class="info-value" id="appVersion">--</span>
<span class="update-badge" id="updateBadge" style="display: none;">
<i class="fas fa-arrow-up"></i> <span id="latestVersionText">--</span>
</span>
</div>
</div>
<div class="info-item">
<span class="info-label">
<i class="fas fa-code"></i> <span data-i18n="dashboard.nodeVersion">Node.js版本</span>
</span>
<div class="version-display-wrapper">
<span class="info-value" id="nodeVersion">--</span>
</div>
</div>
<div class="info-item">
<span class="info-label">
<i class="fas fa-clock"></i> <span data-i18n="dashboard.serverTime">服务器时间</span>
</span>
<div class="version-display-wrapper">
<span class="info-value" id="serverTime">--</span>
</div>
</div>
<div class="info-item">
<span class="info-label">
<i class="fas fa-desktop"></i> <span data-i18n="dashboard.platform">操作系统</span>
</span>
<div class="version-display-wrapper">
<span class="info-value" id="platformInfo">--</span>
</div>
</div>
<div class="info-item">
<span class="info-label">
<i class="fas fa-memory"></i> <span data-i18n="dashboard.memoryUsage">内存使用</span>
</span>
<div class="version-display-wrapper">
<span class="info-value" id="memoryUsage">--</span>
</div>
</div>
<div class="info-item">
<span class="info-label">
<i class="fas fa-microchip"></i> <span data-i18n="dashboard.cpuUsage">CPU 使用</span>
</span>
<div class="version-display-wrapper">
<span class="info-value" id="cpuUsage">--</span>
</div>
</div>
<div class="info-item">
<span class="info-label">
<i class="fas fa-cogs"></i> <span data-i18n="dashboard.serviceMode">运行模式</span>
</span>
<div class="version-display-wrapper">
<span class="info-value" id="serviceMode">--</span>
</div>
</div>
<div class="info-item">
<span class="info-label">
<i class="fas fa-microchip"></i> <span data-i18n="dashboard.processPid">进程 PID</span>
</span>
<div class="version-display-wrapper">
<span class="info-value" id="processPid">--</span>
</div>
</div>
</div>
</div>
<!-- Path Routing Examples Panel -->
<div class="routing-examples-panel">
<h3><i class="fas fa-route"></i> <span data-i18n="dashboard.routing.title">路径路由调用示例</span></h3>
<p class="routing-description" data-i18n="dashboard.routing.description">通过不同路径路由访问不同的AI模型提供商支持灵活的模型切换</p>
<div class="routing-examples-grid">
<div class="routing-example-card" data-provider="gemini-cli-oauth-card">
<div class="routing-card-header">
<i class="fas fa-gem"></i>
<h4 data-i18n="dashboard.routing.nodeName.gemini">Gemini CLI OAuth</h4>
<span class="provider-badge oauth" data-i18n="dashboard.routing.oauth">突破限制</span>
</div>
<div class="routing-card-content">
<!-- 协议标签切换 -->
<div class="protocol-tabs">
<button class="protocol-tab" data-protocol="openai" data-i18n="dashboard.routing.openai">OpenAI协议</button>
<button class="protocol-tab active" data-protocol="claude" data-i18n="dashboard.routing.claude">Claude协议</button>
</div>
<!-- OpenAI协议示例 -->
<div class="protocol-content" data-protocol="openai">
<div class="endpoint-info">
<label data-i18n="dashboard.routing.endpoint">端点路径:</label>
<code class="endpoint-path">/gemini-cli-oauth/v1/chat/completions</code>
</div>
<div class="usage-example">
<label data-i18n="dashboard.routing.exampleOpenAI">使用示例 (OpenAI格式):</label>
<pre><code>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
}'</code></pre>
</div>
</div>
<!-- Claude协议示例 -->
<div class="protocol-content active" data-protocol="claude">
<div class="endpoint-info">
<label data-i18n="dashboard.routing.endpoint">端点路径:</label>
<code class="endpoint-path">/gemini-cli-oauth/v1/messages</code>
</div>
<div class="usage-example">
<label data-i18n="dashboard.routing.exampleClaude">使用示例 (Claude格式):</label>
<pre><code>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!"}]
}'</code></pre>
</div>
</div>
</div>
</div>
<div class="routing-example-card" data-provider="gemini-antigravity-card">
<div class="routing-card-header">
<i class="fas fa-rocket"></i>
<h4 data-i18n="dashboard.routing.nodeName.antigravity">Gemini Antigravity</h4>
<span class="provider-badge oauth" data-i18n="dashboard.routing.experimental">突破限制/实验性</span>
</div>
<div class="routing-card-content">
<!-- 协议标签切换 -->
<div class="protocol-tabs">
<button class="protocol-tab" data-protocol="openai" data-i18n="dashboard.routing.openai">OpenAI协议</button>
<button class="protocol-tab active" data-protocol="claude" data-i18n="dashboard.routing.claude">Claude协议</button>
</div>
<!-- OpenAI协议示例 -->
<div class="protocol-content" data-protocol="openai">
<div class="endpoint-info">
<label data-i18n="dashboard.routing.endpoint">端点路径:</label>
<code class="endpoint-path">/gemini-antigravity/v1/chat/completions</code>
</div>
<div class="usage-example">
<label data-i18n="dashboard.routing.exampleOpenAI">使用示例 (OpenAI格式):</label>
<pre><code>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
}'</code></pre>
</div>
</div>
<!-- Claude协议示例 -->
<div class="protocol-content active" data-protocol="claude">
<div class="endpoint-info">
<label data-i18n="dashboard.routing.endpoint">端点路径:</label>
<code class="endpoint-path">/gemini-antigravity/v1/messages</code>
</div>
<div class="usage-example">
<label data-i18n="dashboard.routing.exampleClaude">使用示例 (Claude格式):</label>
<pre><code>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!"}]
}'</code></pre>
</div>
</div>
</div>
</div>
<div class="routing-example-card" data-provider="claude-custom-card">
<div class="routing-card-header">
<i class="fas fa-brain"></i>
<h4 data-i18n="dashboard.routing.nodeName.claude">Claude Custom</h4>
<span class="provider-badge official" data-i18n="dashboard.routing.official">官方API/三方</span>
</div>
<div class="routing-card-content">
<!-- 协议标签切换 -->
<div class="protocol-tabs">
<button class="protocol-tab" data-protocol="openai" data-i18n="dashboard.routing.openai">OpenAI协议</button>
<button class="protocol-tab active" data-protocol="claude" data-i18n="dashboard.routing.claude">Claude协议</button>
</div>
<!-- OpenAI协议示例 -->
<div class="protocol-content" data-protocol="openai">
<div class="endpoint-info">
<label data-i18n="dashboard.routing.endpoint">端点路径:</label>
<code class="endpoint-path">/claude-custom/v1/chat/completions</code>
</div>
<div class="usage-example">
<label data-i18n="dashboard.routing.exampleOpenAI">使用示例 (OpenAI格式):</label>
<pre><code>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
}'</code></pre>
</div>
</div>
<!-- Claude协议示例 -->
<div class="protocol-content active" data-protocol="claude">
<div class="endpoint-info">
<label data-i18n="dashboard.routing.endpoint">端点路径:</label>
<code class="endpoint-path">/claude-custom/v1/messages</code>
</div>
<div class="usage-example">
<label data-i18n="dashboard.routing.exampleClaude">使用示例 (Claude格式):</label>
<pre><code>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!"}]
}'</code></pre>
</div>
</div>
</div>
</div>
<div class="routing-example-card" data-provider="claude-kiro-oauth-card">
<div class="routing-card-header">
<i class="fas fa-robot"></i>
<h4 data-i18n="dashboard.routing.nodeName.kiro">Claude Kiro OAuth</h4>
<span class="provider-badge oauth" data-i18n="dashboard.routing.free">突破限制/免费使用</span>
</div>
<div class="routing-card-content">
<!-- 协议标签切换 -->
<div class="protocol-tabs">
<button class="protocol-tab" data-protocol="openai" data-i18n="dashboard.routing.openai">OpenAI协议</button>
<button class="protocol-tab active" data-protocol="claude" data-i18n="dashboard.routing.claude">Claude协议</button>
</div>
<!-- OpenAI协议示例 -->
<div class="protocol-content" data-protocol="openai">
<div class="endpoint-info">
<label data-i18n="dashboard.routing.endpoint">端点路径:</label>
<code class="endpoint-path">/claude-kiro-oauth/v1/chat/completions</code>
</div>
<div class="usage-example">
<label data-i18n="dashboard.routing.exampleOpenAI">使用示例 (OpenAI格式):</label>
<pre><code>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
}'</code></pre>
</div>
</div>
<!-- Claude协议示例 -->
<div class="protocol-content active" data-protocol="claude">
<div class="endpoint-info">
<label data-i18n="dashboard.routing.endpoint">端点路径:</label>
<code class="endpoint-path">/claude-kiro-oauth/v1/messages</code>
</div>
<div class="usage-example">
<label data-i18n="dashboard.routing.exampleClaude">使用示例 (Claude格式):</label>
<pre><code>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!"}]
}'</code></pre>
</div>
</div>
</div>
</div>
<div class="routing-example-card" data-provider="openai-custom-card">
<div class="routing-card-header">
<i class="fas fa-comments"></i>
<h4 data-i18n="dashboard.routing.nodeName.openai">OpenAI Custom</h4>
<span class="provider-badge official" data-i18n="dashboard.routing.official">官方API/三方</span>
</div>
<div class="routing-card-content">
<!-- 协议标签切换 -->
<div class="protocol-tabs">
<button class="protocol-tab" data-protocol="openai" data-i18n="dashboard.routing.openai">OpenAI协议</button>
<button class="protocol-tab active" data-protocol="claude" data-i18n="dashboard.routing.claude">Claude协议</button>
</div>
<!-- OpenAI协议示例 -->
<div class="protocol-content" data-protocol="openai">
<div class="endpoint-info">
<label data-i18n="dashboard.routing.endpoint">端点路径:</label>
<code class="endpoint-path">/openai-custom/v1/chat/completions</code>
</div>
<div class="usage-example">
<label data-i18n="dashboard.routing.exampleOpenAI">使用示例 (OpenAI格式):</label>
<pre><code>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
}'</code></pre>
</div>
</div>
<!-- Claude协议示例 -->
<div class="protocol-content active" data-protocol="claude">
<div class="endpoint-info">
<label data-i18n="dashboard.routing.endpoint">端点路径:</label>
<code class="endpoint-path">/openai-custom/v1/messages</code>
</div>
<div class="usage-example">
<label data-i18n="dashboard.routing.exampleClaude">使用示例 (Claude格式):</label>
<pre><code>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!"}]
}'</code></pre>
</div>
</div>
</div>
</div>
<div class="routing-example-card" data-provider="openai-qwen-oauth-card">
<div class="routing-card-header">
<i class="fas fa-code"></i>
<h4 data-i18n="dashboard.routing.nodeName.qwen">Qwen OAuth</h4>
<span class="provider-badge oauth" data-i18n="dashboard.routing.oauth">突破限制</span>
</div>
<div class="routing-card-content">
<!-- 协议标签切换 -->
<div class="protocol-tabs">
<button class="protocol-tab" data-protocol="openai" data-i18n="dashboard.routing.openai">OpenAI协议</button>
<button class="protocol-tab active" data-protocol="claude" data-i18n="dashboard.routing.claude">Claude协议</button>
</div>
<!-- OpenAI协议示例 -->
<div class="protocol-content" data-protocol="openai">
<div class="endpoint-info">
<label data-i18n="dashboard.routing.endpoint">端点路径:</label>
<code class="endpoint-path">/openai-qwen-oauth/v1/chat/completions</code>
</div>
<div class="usage-example">
<label data-i18n="dashboard.routing.exampleOpenAI">使用示例 (OpenAI格式):</label>
<pre><code>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
}'</code></pre>
</div>
</div>
<!-- Claude协议示例 -->
<div class="protocol-content active" data-protocol="claude">
<div class="endpoint-info">
<label data-i18n="dashboard.routing.endpoint">端点路径:</label>
<code class="endpoint-path">/openai-qwen-oauth/v1/messages</code>
</div>
<div class="usage-example">
<label data-i18n="dashboard.routing.exampleClaude">使用示例 (Claude格式):</label>
<pre><code>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!"}]
}'</code></pre>
</div>
</div>
</div>
</div>
<div class="routing-example-card" data-provider="openai-iflow-card">
<div class="routing-card-header">
<i class="fas fa-wind"></i>
<h4 data-i18n="dashboard.routing.nodeName.iflow">iFlow OAuth</h4>
<span class="provider-badge oauth" data-i18n="dashboard.routing.oauth">突破限制</span>
</div>
<div class="routing-card-content">
<!-- 协议标签切换 -->
<div class="protocol-tabs">
<button class="protocol-tab" data-protocol="openai" data-i18n="dashboard.routing.openai">OpenAI协议</button>
<button class="protocol-tab active" data-protocol="claude" data-i18n="dashboard.routing.claude">Claude协议</button>
</div>
<!-- OpenAI协议示例 -->
<div class="protocol-content" data-protocol="openai">
<div class="endpoint-info">
<label data-i18n="dashboard.routing.endpoint">端点路径:</label>
<code class="endpoint-path">/openai-iflow/v1/chat/completions</code>
</div>
<div class="usage-example">
<label data-i18n="dashboard.routing.exampleOpenAI">使用示例 (OpenAI格式):</label>
<pre><code>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
}'</code></pre>
</div>
</div>
<!-- Claude协议示例 -->
<div class="protocol-content active" data-protocol="claude">
<div class="endpoint-info">
<label data-i18n="dashboard.routing.endpoint">端点路径:</label>
<code class="endpoint-path">/openai-iflow/v1/messages</code>
</div>
<div class="usage-example">
<label data-i18n="dashboard.routing.exampleClaude">使用示例 (Claude格式):</label>
<pre><code>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!"}]
}'</code></pre>
</div>
</div>
</div>
</div>
</div>
<div class="routing-tips">
<h4><i class="fas fa-lightbulb"></i> <span data-i18n="dashboard.routing.tips">使用提示</span></h4>
<ul>
<li data-i18n="dashboard.routing.tip1"><strong>即时切换:</strong> 通过修改URL路径即可切换不同的AI模型提供商</li>
<li data-i18n="dashboard.routing.tip2"><strong>客户端配置:</strong> 在Cherry-Studio、NextChat、Cline等客户端中设置API端点为对应路径</li>
<li data-i18n="dashboard.routing.tip3"><strong>跨协议调用:</strong> 支持OpenAI协议调用Claude模型或Claude协议调用OpenAI模型</li>
</ul>
</div>
</div>
</section>

View file

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

View file

@ -0,0 +1,16 @@
<link rel="stylesheet" href="components/section-logs.css">
<!-- Logs Section -->
<section id="logs" class="section" aria-labelledby="logs-title">
<h2 id="logs-title" data-i18n="logs.title">实时日志</h2>
<div class="logs-controls">
<button class="btn btn-danger" id="clearLogs" aria-label="Clear All Logs" data-i18n-aria-label="logs.clear">
<i class="fas fa-trash" aria-hidden="true"></i> <span data-i18n="logs.clear">清空日志</span>
</button>
<button class="btn btn-primary" id="toggleAutoScroll" data-enabled="true" aria-label="Toggle Auto-scroll" data-i18n-aria-label="logs.autoScroll">
<i class="fas fa-arrow-down" aria-hidden="true"></i> <span data-i18n="logs.autoScroll.on">自动滚动: 开</span>
</button>
</div>
<div class="logs-container" id="logsContainer" role="log" aria-live="polite" aria-atomic="false">
<!-- Logs will appear here -->
</div>
</section>

View file

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

View file

@ -0,0 +1,67 @@
<link rel="stylesheet" href="components/section-plugins.css">
<!-- Plugins Section -->
<section id="plugins" class="section" aria-labelledby="plugins-title">
<h2 id="plugins-title" data-i18n="plugins.title">插件管理</h2>
<div class="plugins-panel">
<div class="plugins-description">
<div class="highlight-note">
<i class="fas fa-info-circle"></i>
<span data-i18n="plugins.description">插件系统允许您扩展系统功能,启用或禁用插件需要重启服务才能生效</span>
</div>
</div>
<!-- 插件统计 -->
<div class="stats-grid plugins-stats">
<div class="stat-card">
<div class="stat-icon">
<i class="fas fa-puzzle-piece"></i>
</div>
<div class="stat-info">
<h3 id="totalPlugins">0</h3>
<p data-i18n="plugins.stats.total">总插件数</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<i class="fas fa-check-circle"></i>
</div>
<div class="stat-info">
<h3 id="enabledPlugins">0</h3>
<p data-i18n="plugins.stats.enabled">已启用</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<i class="fas fa-times-circle"></i>
</div>
<div class="stat-info">
<h3 id="disabledPlugins">0</h3>
<p data-i18n="plugins.stats.disabled">已禁用</p>
</div>
</div>
</div>
<!-- 插件操作按钮 -->
<div class="plugins-controls">
<button class="btn btn-primary" id="refreshPluginsBtn" aria-label="Refresh Plugins" data-i18n-aria-label="plugins.refresh">
<i class="fas fa-sync-alt"></i> <span data-i18n="plugins.refresh">刷新插件列表</span>
</button>
</div>
<!-- 插件加载状态 -->
<div class="plugins-loading" id="pluginsLoading" style="display: none;">
<i class="fas fa-spinner fa-spin"></i> <span data-i18n="plugins.loading">正在加载插件列表...</span>
</div>
<!-- 插件列表 -->
<div class="plugins-list-container">
<div id="pluginsList" class="plugins-list">
<!-- 插件列表将在这里动态生成 -->
</div>
<div class="plugins-empty" id="pluginsEmpty">
<i class="fas fa-puzzle-piece"></i>
<p data-i18n="plugins.empty">暂无已安装的插件</p>
</div>
</div>
</div>
</section>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,46 @@
<link rel="stylesheet" href="components/section-providers.css">
<!-- Provider Pools Section -->
<section id="providers" class="section" aria-labelledby="providers-title">
<h2 id="providers-title" data-i18n="providers.title">提供商池管理</h2>
<div class="pool-description">
<div class="highlight-note">
<i class="fas fa-info-circle"></i>
<span data-i18n="providers.note">使用默认路径配置需添加一个空节点</span>
</div>
</div>
<!-- Provider Pool Stats -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon">
<i class="fas fa-server"></i>
</div>
<div class="stat-info">
<h3 id="activeConnections">0</h3>
<p data-i18n="providers.activeConnections">活动连接</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<i class="fas fa-network-wired"></i>
</div>
<div class="stat-info">
<h3 id="activeProviders">0</h3>
<p data-i18n="providers.activeProviders">活跃提供商</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<i class="fas fa-check-circle"></i>
</div>
<div class="stat-info">
<h3 id="healthyProviders">0</h3>
<p data-i18n="providers.healthyProviders">健康提供商</p>
</div>
</div>
</div>
<div class="providers-container">
<div id="providersList" class="providers-list">
<!-- Providers will be loaded here -->
</div>
</div>
</section>

View file

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

View file

@ -0,0 +1,58 @@
<link rel="stylesheet" href="components/section-upload-config.css">
<!-- Upload Configuration Section -->
<section id="upload-config" class="section" aria-labelledby="upload-config-title">
<h2 id="upload-config-title" data-i18n="upload.title">配置管理</h2>
<div class="upload-config-panel">
<!-- 搜索和过滤区域 -->
<div class="config-search-panel">
<div class="search-controls">
<div class="form-group">
<label for="configSearch" data-i18n="upload.search">搜索配置</label>
<div class="search-input-group">
<input type="text" id="configSearch" class="form-control" data-i18n-placeholder="upload.searchPlaceholder" placeholder="输入文件名">
<button type="button" class="btn btn-outline" id="searchConfigBtn" data-i18n-title="common.search" aria-label="搜索配置" data-i18n-aria-label="common.search">
<i class="fas fa-search"></i>
</button>
</div>
</div>
<div class="form-group">
<label for="configStatusFilter" data-i18n="upload.statusFilter">关联状态</label>
<select id="configStatusFilter" class="form-control">
<option value="" data-i18n="upload.statusFilter.all">全部状态</option>
<option value="used" data-i18n="upload.statusFilter.used">已关联</option>
<option value="unused" data-i18n="upload.statusFilter.unused">未关联</option>
</select>
</div>
<div class="form-group">
<label>&nbsp;</label>
<div class="config-actions">
<button class="btn btn-outline" id="refreshConfigList" aria-label="Refresh Config List" data-i18n-aria-label="upload.refresh">
<i class="fas fa-sync-alt"></i> <span data-i18n="upload.refresh">刷新</span>
</button>
<button class="btn btn-outline" id="downloadAllConfigs" aria-label="Download All Configs" data-i18n-aria-label="upload.downloadAll">
<i class="fas fa-file-archive"></i> <span data-i18n="upload.downloadAll">打包下载</span>
</button>
</div>
</div>
</div>
</div>
<!-- 配置列表 -->
<div class="config-list-container">
<div class="config-list-header">
<h3 data-i18n="upload.listTitle">配置文件列表</h3>
<div class="config-stats">
<span id="configCount" data-i18n="upload.count" data-i18n-params='{"count":"0"}'>共 0 个配置文件</span>
<span id="usedConfigCount" class="status-used" data-i18n="upload.usedCount" data-i18n-params='{"count":"0"}'>已关联: 0</span>
<span id="unusedConfigCount" class="status-unused" data-i18n="upload.unusedCount" data-i18n-params='{"count":"0"}'>未关联: 0</span>
<button id="batchLinkKiroBtn" class="btn-batch-link" data-i18n-title="upload.batchLink" title="批量关联 configs/ 下的未关联配置">
<i class="fas fa-link"></i> <span data-i18n="upload.batchLink">自动关联oauth</span>
</button>
</div>
</div>
<div id="configList" class="config-list">
<!-- 配置文件列表将在这里动态生成 -->
</div>
</div>
</div>
</section>

View file

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

View file

@ -0,0 +1,30 @@
<link rel="stylesheet" href="components/section-usage.css">
<!-- Usage Section -->
<section id="usage" class="section" aria-labelledby="usage-title">
<h2 id="usage-title" data-i18n="usage.title">用量查询</h2>
<div class="usage-panel">
<div class="usage-controls">
<button class="btn btn-primary" id="refreshUsageBtn" aria-label="Refresh Usage" data-i18n-aria-label="usage.refresh">
<i class="fas fa-sync-alt"></i> <span data-i18n="usage.refresh">刷新用量</span>
</button>
<span class="usage-last-update" id="usageLastUpdate" data-i18n="usage.lastUpdate" data-i18n-params='{"time":"--"}'>上次更新: --</span>
</div>
<div class="usage-loading" id="usageLoading" style="display: none;">
<i class="fas fa-spinner fa-spin"></i> <span data-i18n="usage.loading">正在加载用量数据...</span>
</div>
<div class="usage-error" id="usageError" style="display: none;">
<i class="fas fa-exclamation-triangle"></i>
<span id="usageErrorMessage"></span>
</div>
<div class="usage-content" id="usageContent">
<!-- 用量数据将在这里动态生成 -->
<div class="usage-empty" id="usageEmpty">
<i class="fas fa-chart-bar"></i>
<p data-i18n="usage.empty">点击"刷新用量"按钮获取授权文件用量信息</p>
</div>
</div>
</div>
</section>

View file

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

View file

@ -0,0 +1,27 @@
<link rel="stylesheet" href="components/sidebar.css">
<!-- Sidebar -->
<aside class="sidebar" role="navigation" aria-label="Main Navigation" data-i18n-aria-label="nav.main">
<nav class="sidebar-nav">
<a href="#dashboard" class="nav-item active" data-section="dashboard" aria-label="Dashboard" data-i18n-aria-label="nav.dashboard">
<i class="fas fa-tachometer-alt" aria-hidden="true"></i> <span data-i18n="nav.dashboard">仪表盘</span>
</a>
<a href="#config" class="nav-item" data-section="config" aria-label="Config Management" data-i18n-aria-label="nav.config">
<i class="fas fa-cog" aria-hidden="true"></i> <span data-i18n="nav.config">配置管理</span>
</a>
<a href="#providers" class="nav-item" data-section="providers" aria-label="Provider Pool Management" data-i18n-aria-label="nav.providers">
<i class="fas fa-network-wired" aria-hidden="true"></i> <span data-i18n="nav.providers">提供商池管理</span>
</a>
<a href="#upload-config" class="nav-item" data-section="upload-config" aria-label="Upload Config Management" data-i18n-aria-label="nav.upload">
<i class="fas fa-upload" aria-hidden="true"></i> <span data-i18n="nav.upload">配置管理</span>
</a>
<a href="#usage" class="nav-item" data-section="usage" aria-label="Usage Query" data-i18n-aria-label="nav.usage">
<i class="fas fa-chart-bar" aria-hidden="true"></i> <span data-i18n="nav.usage">用量查询</span>
</a>
<a href="#plugins" class="nav-item" data-section="plugins" aria-label="Plugin Management" data-i18n-aria-label="nav.plugins">
<i class="fas fa-puzzle-piece" aria-hidden="true"></i> <span data-i18n="nav.plugins">插件管理</span>
</a>
<a href="#logs" class="nav-item" data-section="logs" aria-label="Real-time Logs" data-i18n-aria-label="nav.logs">
<i class="fas fa-file-alt" aria-hidden="true"></i> <span data-i18n="nav.logs">实时日志</span>
</a>
</nav>
</aside>

File diff suppressed because it is too large Load diff