feat(ui): 重构前端UI组件并添加新功能
- 新增组件加载器实现动态加载HTML组件 - 重构导航功能,添加滚动到顶部功能 - 新增多个UI组件:header、sidebar、logs、usage等 - 实现移动端菜单响应式设计 - 优化DOM元素获取方式,使用延迟加载 - 新增系统监控模块和用量缓存功能 - 扩展静态文件服务支持/components路径 - 实现插件管理和系统API接口 - 添加配置上传和管理功能 - 完善认证和token管理机制
This commit is contained in:
parent
bf11211a77
commit
4554a4cfd2
41 changed files with 10047 additions and 10475 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
3272
src/ui-manager.js
3272
src/ui-manager.js
File diff suppressed because it is too large
Load diff
259
src/ui-modules/auth.js
Normal file
259
src/ui-modules/auth.js
Normal 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分钟清理一次
|
||||
262
src/ui-modules/config-api.js
Normal file
262
src/ui-modules/config-api.js
Normal 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;
|
||||
}
|
||||
}
|
||||
343
src/ui-modules/config-scanner.js
Normal file
343
src/ui-modules/config-scanner.js
Normal 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;
|
||||
}
|
||||
269
src/ui-modules/event-broadcast.js
Normal file
269
src/ui-modules/event-broadcast.js
Normal 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
306
src/ui-modules/oauth-api.js
Normal 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;
|
||||
}
|
||||
}
|
||||
76
src/ui-modules/plugin-api.js
Normal file
76
src/ui-modules/plugin-api.js
Normal 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;
|
||||
}
|
||||
}
|
||||
707
src/ui-modules/provider-api.js
Normal file
707
src/ui-modules/provider-api.js
Normal 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;
|
||||
}
|
||||
}
|
||||
120
src/ui-modules/system-api.js
Normal file
120
src/ui-modules/system-api.js
Normal 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;
|
||||
}
|
||||
}
|
||||
42
src/ui-modules/system-monitor.js
Normal file
42
src/ui-modules/system-monitor.js
Normal 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)}%`;
|
||||
}
|
||||
495
src/ui-modules/update-api.js
Normal file
495
src/ui-modules/update-api.js
Normal 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;
|
||||
}
|
||||
}
|
||||
200
src/ui-modules/upload-config-api.js
Normal file
200
src/ui-modules/upload-config-api.js
Normal 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
290
src/ui-modules/usage-api.js
Normal 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;
|
||||
}
|
||||
}
|
||||
71
src/ui-modules/usage-cache.js
Normal file
71
src/ui-modules/usage-cache.js
Normal 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);
|
||||
}
|
||||
|
|
@ -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
694
static/app/base.css
Normal 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;
|
||||
}
|
||||
}
|
||||
137
static/app/component-loader.js
Normal file
137
static/app/component-loader.js
Normal 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
|
||||
};
|
||||
|
|
@ -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
|
|
@ -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
161
static/components/header.css
Normal file
161
static/components/header.css
Normal 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);
|
||||
}
|
||||
31
static/components/header.html
Normal file
31
static/components/header.html
Normal 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>
|
||||
383
static/components/section-config.css
Normal file
383
static/components/section-config.css
Normal 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);
|
||||
}
|
||||
234
static/components/section-config.html
Normal file
234
static/components/section-config.html
Normal 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>
|
||||
416
static/components/section-dashboard.css
Normal file
416
static/components/section-dashboard.css
Normal 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);
|
||||
}
|
||||
512
static/components/section-dashboard.html
Normal file
512
static/components/section-dashboard.html
Normal 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>
|
||||
89
static/components/section-logs.css
Normal file
89
static/components/section-logs.css
Normal 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);
|
||||
}
|
||||
16
static/components/section-logs.html
Normal file
16
static/components/section-logs.html
Normal 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>
|
||||
151
static/components/section-plugins.css
Normal file
151
static/components/section-plugins.css
Normal 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); }
|
||||
67
static/components/section-plugins.html
Normal file
67
static/components/section-plugins.html
Normal 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>
|
||||
1419
static/components/section-providers.css
Normal file
1419
static/components/section-providers.css
Normal file
File diff suppressed because it is too large
Load diff
46
static/components/section-providers.html
Normal file
46
static/components/section-providers.html
Normal 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>
|
||||
408
static/components/section-upload-config.css
Normal file
408
static/components/section-upload-config.css
Normal 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); }
|
||||
58
static/components/section-upload-config.html
Normal file
58
static/components/section-upload-config.html
Normal 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> </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>
|
||||
369
static/components/section-usage.css
Normal file
369
static/components/section-usage.css
Normal 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); }
|
||||
30
static/components/section-usage.html
Normal file
30
static/components/section-usage.html
Normal 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>
|
||||
67
static/components/sidebar.css
Normal file
67
static/components/sidebar.css
Normal 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;
|
||||
}
|
||||
}
|
||||
27
static/components/sidebar.html
Normal file
27
static/components/sidebar.html
Normal 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>
|
||||
1091
static/index.html
1091
static/index.html
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue