feat(插件系统): 实现插件管理功能
添加完整的插件管理系统,包括以下功能: 1. 插件列表展示与状态统计 2. 插件启用/禁用功能 3. 自动扫描插件目录生成默认配置 4. 插件管理界面和样式 5. 相关API接口实现 6. 多语言支持 7. 移除不再需要的公共API路径配置
This commit is contained in:
parent
d639077bde
commit
e797b4742a
10 changed files with 590 additions and 48 deletions
|
|
@ -67,15 +67,8 @@ class PluginManager {
|
|||
const content = await fs.readFile(PLUGINS_CONFIG_FILE, 'utf8');
|
||||
this.pluginsConfig = JSON.parse(content);
|
||||
} else {
|
||||
// 创建默认配置
|
||||
this.pluginsConfig = {
|
||||
plugins: {
|
||||
'api-potluck': {
|
||||
enabled: true,
|
||||
description: 'API 大锅饭 - Key 管理和用量统计'
|
||||
}
|
||||
}
|
||||
};
|
||||
// 扫描 plugins 目录生成默认配置
|
||||
this.pluginsConfig = await this.generateDefaultConfig();
|
||||
await this.saveConfig();
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -84,6 +77,55 @@ class PluginManager {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描 plugins 目录生成默认配置
|
||||
* @returns {Promise<Object>} 默认插件配置
|
||||
*/
|
||||
async generateDefaultConfig() {
|
||||
const defaultConfig = { plugins: {} };
|
||||
const pluginsDir = path.join(process.cwd(), 'src', 'plugins');
|
||||
|
||||
try {
|
||||
if (!existsSync(pluginsDir)) {
|
||||
return defaultConfig;
|
||||
}
|
||||
|
||||
const entries = await fs.readdir(pluginsDir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
|
||||
const pluginPath = path.join(pluginsDir, entry.name, 'index.js');
|
||||
if (!existsSync(pluginPath)) continue;
|
||||
|
||||
try {
|
||||
// 动态导入插件以获取其元信息
|
||||
const pluginModule = await import(`file://${pluginPath}`);
|
||||
const plugin = pluginModule.default || pluginModule;
|
||||
|
||||
if (plugin && plugin.name) {
|
||||
defaultConfig.plugins[plugin.name] = {
|
||||
enabled: true,
|
||||
description: plugin.description || ''
|
||||
};
|
||||
console.log(`[PluginManager] Found plugin for default config: ${plugin.name}`);
|
||||
}
|
||||
} catch (importError) {
|
||||
// 如果导入失败,使用目录名作为插件名
|
||||
defaultConfig.plugins[entry.name] = {
|
||||
enabled: true,
|
||||
description: ''
|
||||
};
|
||||
console.warn(`[PluginManager] Could not import plugin ${entry.name}, using directory name:`, importError.message);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[PluginManager] Failed to scan plugins directory:', error.message);
|
||||
}
|
||||
|
||||
return defaultConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存插件配置文件
|
||||
*/
|
||||
|
|
@ -370,30 +412,6 @@ class PluginManager {
|
|||
return staticPaths.some(sp => path === sp || path === '/' + sp);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有插件的公开 API 路径(不需要 UI 管理 API token 验证)
|
||||
* @returns {string[]}
|
||||
*/
|
||||
getPublicApiPaths() {
|
||||
const paths = [];
|
||||
for (const plugin of this.getEnabledPlugins()) {
|
||||
if (Array.isArray(plugin.publicApiPaths)) {
|
||||
paths.push(...plugin.publicApiPaths);
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查路径是否是插件公开 API 路径(不需要 UI 管理 API token 验证)
|
||||
* @param {string} path - 请求路径
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isPluginPublicApiPath(path) {
|
||||
const publicPaths = this.getPublicApiPaths();
|
||||
return publicPaths.some(pp => path === pp || path.startsWith(pp + '/'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行钩子函数
|
||||
* @param {string} hookName - 钩子名称
|
||||
|
|
|
|||
|
|
@ -68,12 +68,6 @@ const apiPotluckPlugin = {
|
|||
*/
|
||||
staticPaths: ['potluck.html', 'potluck-user.html'],
|
||||
|
||||
/**
|
||||
* 公开 API 路径(不需要 UI 管理 API 的 token 验证)
|
||||
* 这些路径将跳过 handleUIApiRequests 中的 checkAuth 验证
|
||||
*/
|
||||
publicApiPaths: ['/api/potluckuser'],
|
||||
|
||||
/**
|
||||
* 路由定义
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -63,13 +63,13 @@ export function createRequestHandler(config, providerPoolManager) {
|
|||
if (served) return;
|
||||
}
|
||||
|
||||
const uiHandled = await handleUIApiRequests(method, path, req, res, currentConfig, providerPoolManager);
|
||||
if (uiHandled) return;
|
||||
|
||||
// 执行插件路由
|
||||
const pluginRouteHandled = await pluginManager.executeRoutes(method, path, req, res);
|
||||
if (pluginRouteHandled) return;
|
||||
|
||||
const uiHandled = await handleUIApiRequests(method, path, req, res, currentConfig, providerPoolManager);
|
||||
if (uiHandled) return;
|
||||
|
||||
// Ollama show endpoint with model name
|
||||
if (method === 'POST' && path === '/ollama/api/show') {
|
||||
await handleOllamaShow(req, res);
|
||||
|
|
|
|||
|
|
@ -56,8 +56,8 @@ import { getAllProviderModels, getProviderModels } from './provider-models.js';
|
|||
import { CONFIG } from './config-manager.js';
|
||||
import { serviceInstances, getServiceAdapter } from './adapter.js';
|
||||
import { initApiService } from './service-manager.js';
|
||||
import { getPluginManager } from './plugin-manager.js';
|
||||
import { handleGeminiCliOAuth, handleGeminiAntigravityOAuth, handleQwenOAuth, handleKiroOAuth, handleIFlowOAuth, batchImportKiroRefreshTokens, batchImportKiroRefreshTokensStream, importAwsCredentials } from './oauth-handlers.js';
|
||||
import { getPluginManager } from './plugin-manager.js';
|
||||
import {
|
||||
generateUUID,
|
||||
normalizePath,
|
||||
|
|
@ -518,10 +518,8 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo
|
|||
return true;
|
||||
}
|
||||
|
||||
// Handle UI management API requests (需要token验证,除了登录接口、健康检查、Events接口和插件公开API路径)
|
||||
const pluginManager = getPluginManager();
|
||||
const isPluginPublicApi = pluginManager.isPluginPublicApiPath(pathParam);
|
||||
if (pathParam.startsWith('/api/') && pathParam !== '/api/login' && pathParam !== '/api/health' && pathParam !== '/api/events' && !isPluginPublicApi) {
|
||||
// Handle UI management API requests (需要token验证,除了登录接口、健康检查和Events接口)
|
||||
if (pathParam.startsWith('/api/') && pathParam !== '/api/login' && pathParam !== '/api/health' && pathParam !== '/api/events' ) {
|
||||
// 检查token验证
|
||||
const isAuth = await checkAuth(req);
|
||||
if (!isAuth) {
|
||||
|
|
@ -2196,6 +2194,77 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo
|
|||
}
|
||||
}
|
||||
|
||||
// Get plugins list
|
||||
if (method === 'GET' && pathParam === '/api/plugins') {
|
||||
try {
|
||||
const pluginManager = getPluginManager();
|
||||
const plugins = pluginManager.getPluginList();
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ plugins }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[UI API] Failed to get plugins:', error);
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
error: {
|
||||
message: 'Failed to get plugins list: ' + error.message
|
||||
}
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle plugin status
|
||||
const togglePluginMatch = pathParam.match(/^\/api\/plugins\/(.+)\/toggle$/);
|
||||
if (method === 'POST' && togglePluginMatch) {
|
||||
try {
|
||||
const pluginName = decodeURIComponent(togglePluginMatch[1]);
|
||||
const body = await getRequestBody(req);
|
||||
const { enabled } = body;
|
||||
|
||||
if (typeof enabled !== 'boolean') {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
error: {
|
||||
message: 'Enabled status must be a boolean'
|
||||
}
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
const pluginManager = getPluginManager();
|
||||
await pluginManager.setPluginEnabled(pluginName, enabled);
|
||||
|
||||
// 广播更新事件
|
||||
broadcastEvent('plugin_update', {
|
||||
action: 'toggle',
|
||||
pluginName,
|
||||
enabled,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
success: true,
|
||||
message: `Plugin ${pluginName} ${enabled ? 'enabled' : 'disabled'} successfully`,
|
||||
plugin: {
|
||||
name: pluginName,
|
||||
enabled
|
||||
}
|
||||
}));
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[UI API] Failed to toggle plugin:', error);
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
error: {
|
||||
message: 'Failed to toggle plugin: ' + error.message
|
||||
}
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -76,6 +76,11 @@ import {
|
|||
initImageZoom
|
||||
} from './image-zoom.js';
|
||||
|
||||
import {
|
||||
initPluginManager,
|
||||
togglePlugin
|
||||
} from './plugin-manager.js';
|
||||
|
||||
/**
|
||||
* 加载初始数据
|
||||
*/
|
||||
|
|
@ -111,6 +116,7 @@ function initApp() {
|
|||
initUploadConfigManager(); // 初始化配置管理功能
|
||||
initUsageManager(); // 初始化用量管理功能
|
||||
initImageZoom(); // 初始化图片放大功能
|
||||
initPluginManager(); // 初始化插件管理功能
|
||||
loadInitialData();
|
||||
|
||||
// 显示欢迎消息
|
||||
|
|
@ -164,6 +170,9 @@ window.reloadConfig = reloadConfig;
|
|||
// 用量管理相关全局函数
|
||||
window.refreshUsage = refreshUsage;
|
||||
|
||||
// 插件管理相关全局函数
|
||||
window.togglePlugin = togglePlugin;
|
||||
|
||||
// 导出调试函数
|
||||
window.getProviderStats = () => getProviderStats(providerStats);
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ const translations = {
|
|||
'nav.upload': '配置管理',
|
||||
'nav.usage': '用量查询',
|
||||
'nav.logs': '实时日志',
|
||||
'nav.plugins': '插件管理',
|
||||
|
||||
// Dashboard
|
||||
'dashboard.title': '系统概览',
|
||||
|
|
@ -436,6 +437,26 @@ const translations = {
|
|||
'logs.autoScroll.on': '自动滚动: 开',
|
||||
'logs.autoScroll.off': '自动滚动: 关',
|
||||
|
||||
// Plugins
|
||||
'plugins.title': '插件管理',
|
||||
'plugins.description': '插件系统允许您扩展系统功能,启用或禁用插件需要重启服务才能生效',
|
||||
'plugins.stats.total': '总插件数',
|
||||
'plugins.stats.enabled': '已启用',
|
||||
'plugins.stats.disabled': '已禁用',
|
||||
'plugins.refresh': '刷新插件列表',
|
||||
'plugins.loading': '正在加载插件列表...',
|
||||
'plugins.empty': '暂无已安装的插件',
|
||||
'plugins.noDescription': '暂无描述',
|
||||
'plugins.status.enabled': '已启用',
|
||||
'plugins.status.disabled': '已禁用',
|
||||
'plugins.badge.middleware.title': '包含中间件',
|
||||
'plugins.badge.routes.title': '包含路由',
|
||||
'plugins.badge.hooks.title': '包含钩子',
|
||||
'plugins.toggle.success': '插件 {name} 已{status}',
|
||||
'plugins.toggle.failed': '切换插件状态失败',
|
||||
'plugins.load.failed': '加载插件列表失败',
|
||||
'plugins.restart.required': '更改已保存,请重启服务以生效',
|
||||
|
||||
// Common
|
||||
'common.confirm': '确定',
|
||||
'common.cancel': '取消',
|
||||
|
|
@ -505,6 +526,7 @@ const translations = {
|
|||
'nav.upload': 'Config Management',
|
||||
'nav.usage': 'Usage Query',
|
||||
'nav.logs': 'Real-time Logs',
|
||||
'nav.plugins': 'Plugin Management',
|
||||
|
||||
// Dashboard
|
||||
'dashboard.title': 'System Overview',
|
||||
|
|
@ -910,11 +932,33 @@ const translations = {
|
|||
'logs.autoScroll.on': 'Auto Scroll: On',
|
||||
'logs.autoScroll.off': 'Auto Scroll: Off',
|
||||
|
||||
// Plugins
|
||||
'plugins.title': 'Plugin Management',
|
||||
'plugins.description': 'The plugin system allows you to extend system functionality. Enabling or disabling plugins requires a service restart to take effect.',
|
||||
'plugins.stats.total': 'Total Plugins',
|
||||
'plugins.stats.enabled': 'Enabled',
|
||||
'plugins.stats.disabled': 'Disabled',
|
||||
'plugins.refresh': 'Refresh Plugins',
|
||||
'plugins.loading': 'Loading plugins...',
|
||||
'plugins.empty': 'No installed plugins',
|
||||
'plugins.noDescription': 'No description',
|
||||
'plugins.status.enabled': 'Enabled',
|
||||
'plugins.status.disabled': 'Disabled',
|
||||
'plugins.badge.middleware.title': 'Contains Middleware',
|
||||
'plugins.badge.routes.title': 'Contains Routes',
|
||||
'plugins.badge.hooks.title': 'Contains Hooks',
|
||||
'plugins.toggle.success': 'Plugin {name} {status}',
|
||||
'plugins.toggle.failed': 'Failed to toggle plugin status',
|
||||
'plugins.load.failed': 'Failed to load plugins list',
|
||||
'plugins.restart.required': 'Changes saved, please restart service to take effect',
|
||||
|
||||
// Common
|
||||
'common.togglePassword': 'Show/Hide Password',
|
||||
'common.confirm': 'Confirm',
|
||||
'common.cancel': 'Cancel',
|
||||
'common.success': 'Success',
|
||||
'common.enabled': 'Enabled',
|
||||
'common.disabled': 'Disabled',
|
||||
'common.error': 'Error',
|
||||
'common.warning': 'Warning',
|
||||
'common.info': 'Info',
|
||||
|
|
|
|||
141
static/app/plugin-manager.js
Normal file
141
static/app/plugin-manager.js
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import { t } from './i18n.js';
|
||||
import { showToast, apiRequest } from './utils.js';
|
||||
|
||||
// 插件列表状态
|
||||
let pluginsList = [];
|
||||
|
||||
/**
|
||||
* 初始化插件管理器
|
||||
*/
|
||||
export function initPluginManager() {
|
||||
const refreshBtn = document.getElementById('refreshPluginsBtn');
|
||||
if (refreshBtn) {
|
||||
refreshBtn.addEventListener('click', loadPlugins);
|
||||
}
|
||||
|
||||
// 初始加载
|
||||
loadPlugins();
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载插件列表
|
||||
*/
|
||||
export async function loadPlugins() {
|
||||
const loadingEl = document.getElementById('pluginsLoading');
|
||||
const emptyEl = document.getElementById('pluginsEmpty');
|
||||
const listEl = document.getElementById('pluginsList');
|
||||
const totalEl = document.getElementById('totalPlugins');
|
||||
const enabledEl = document.getElementById('enabledPlugins');
|
||||
const disabledEl = document.getElementById('disabledPlugins');
|
||||
|
||||
if (loadingEl) loadingEl.style.display = 'block';
|
||||
if (emptyEl) emptyEl.style.display = 'none';
|
||||
if (listEl) listEl.innerHTML = '';
|
||||
|
||||
try {
|
||||
const response = await apiRequest('/api/plugins');
|
||||
|
||||
if (response && response.plugins) {
|
||||
pluginsList = response.plugins;
|
||||
renderPluginsList();
|
||||
|
||||
// 更新统计信息
|
||||
if (totalEl) totalEl.textContent = pluginsList.length;
|
||||
if (enabledEl) enabledEl.textContent = pluginsList.filter(p => p.enabled).length;
|
||||
if (disabledEl) disabledEl.textContent = pluginsList.filter(p => !p.enabled).length;
|
||||
} else {
|
||||
if (emptyEl) emptyEl.style.display = 'flex';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load plugins:', error);
|
||||
showToast(t('common.error'), t('plugins.load.failed'), 'error');
|
||||
if (emptyEl) emptyEl.style.display = 'flex';
|
||||
} finally {
|
||||
if (loadingEl) loadingEl.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染插件列表
|
||||
*/
|
||||
function renderPluginsList() {
|
||||
const listEl = document.getElementById('pluginsList');
|
||||
const emptyEl = document.getElementById('pluginsEmpty');
|
||||
|
||||
if (!listEl) return;
|
||||
|
||||
if (pluginsList.length === 0) {
|
||||
if (emptyEl) emptyEl.style.display = 'flex';
|
||||
return;
|
||||
}
|
||||
|
||||
if (emptyEl) emptyEl.style.display = 'none';
|
||||
|
||||
pluginsList.forEach(plugin => {
|
||||
const card = document.createElement('div');
|
||||
card.className = `plugin-card ${plugin.enabled ? 'enabled' : 'disabled'}`;
|
||||
|
||||
// 构建标签 HTML
|
||||
let badgesHtml = '';
|
||||
if (plugin.hasMiddleware) {
|
||||
badgesHtml += `<span class="plugin-badge middleware" title="${t('plugins.badge.middleware.title')}">Middleware</span>`;
|
||||
}
|
||||
if (plugin.hasRoutes) {
|
||||
badgesHtml += `<span class="plugin-badge routes" title="${t('plugins.badge.routes.title')}">Routes</span>`;
|
||||
}
|
||||
if (plugin.hasHooks) {
|
||||
badgesHtml += `<span class="plugin-badge hooks" title="${t('plugins.badge.hooks.title')}">Hooks</span>`;
|
||||
}
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="plugin-header">
|
||||
<div class="plugin-title">
|
||||
<h3>${plugin.name}</h3>
|
||||
<span class="plugin-version">v${plugin.version}</span>
|
||||
</div>
|
||||
<div class="plugin-actions">
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" ${plugin.enabled ? 'checked' : ''} onchange="window.togglePlugin('${plugin.name}', this.checked)">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="plugin-description">${plugin.description || t('plugins.noDescription')}</div>
|
||||
<div class="plugin-badges">
|
||||
${badgesHtml}
|
||||
</div>
|
||||
<div class="plugin-status">
|
||||
<i class="fas fa-circle"></i> <span>${plugin.enabled ? t('plugins.status.enabled') : t('plugins.status.disabled')}</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
listEl.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换插件启用状态
|
||||
* @param {string} pluginName - 插件名称
|
||||
* @param {boolean} enabled - 是否启用
|
||||
*/
|
||||
export async function togglePlugin(pluginName, enabled) {
|
||||
try {
|
||||
await apiRequest(`/api/plugins/${encodeURIComponent(pluginName)}/toggle`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ enabled })
|
||||
});
|
||||
|
||||
showToast(t('common.success'), t('plugins.toggle.success', { name: pluginName, status: enabled ? t('common.enabled') : t('common.disabled') }), 'success');
|
||||
|
||||
// 重新加载列表以更新状态
|
||||
loadPlugins();
|
||||
|
||||
// 提示需要重启
|
||||
showToast(t('common.info'), t('plugins.restart.required'), 'info');
|
||||
} catch (error) {
|
||||
console.error(`Failed to toggle plugin ${pluginName}:`, error);
|
||||
showToast(t('common.error'), t('plugins.toggle.failed'), 'error');
|
||||
// 恢复开关状态
|
||||
loadPlugins();
|
||||
}
|
||||
}
|
||||
|
|
@ -5850,3 +5850,186 @@ body {
|
|||
.provider-modal-content {
|
||||
transition: background-color 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
/* ==================== 插件管理样式 ==================== */
|
||||
.plugins-panel {
|
||||
background: var(--bg-primary);
|
||||
padding: 2rem;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.plugins-description {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.plugins-stats {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.plugins-controls {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.plugins-list-container {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.plugins-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.plugin-card {
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
transition: var(--transition);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.plugin-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.plugin-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.plugin-title h3 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.plugin-version {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-tertiary);
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
margin-top: 0.25rem;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.plugin-description {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.plugin-badges {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.plugin-badge {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.plugin-badge.middleware {
|
||||
background: var(--info-bg);
|
||||
color: var(--info-text);
|
||||
}
|
||||
|
||||
.plugin-badge.routes {
|
||||
background: var(--success-bg);
|
||||
color: var(--success-text);
|
||||
}
|
||||
|
||||
.plugin-badge.hooks {
|
||||
background: var(--warning-bg);
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
.plugin-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.plugin-card.enabled .plugin-status {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.plugin-card.disabled .plugin-status {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.plugin-card.disabled {
|
||||
opacity: 0.8;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.plugins-loading,
|
||||
.plugins-empty {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.plugins-empty {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.plugins-empty i {
|
||||
font-size: 3rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* 暗黑主题适配 */
|
||||
[data-theme="dark"] .plugins-list-container {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .plugin-card {
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .plugin-card.disabled {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .plugin-badge.middleware {
|
||||
background: var(--info-bg);
|
||||
color: var(--info-text);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .plugin-badge.routes {
|
||||
background: var(--success-bg);
|
||||
color: var(--success-text);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .plugin-badge.hooks {
|
||||
background: var(--warning-bg);
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
// 工具函数
|
||||
import { t, getCurrentLanguage } from './i18n.js';
|
||||
import { apiClient } from './auth.js';
|
||||
|
||||
/**
|
||||
* 格式化运行时间
|
||||
|
|
@ -275,6 +276,18 @@ function getProviderStats(providerStats) {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用 API 请求函数
|
||||
* @param {string} url - API 端点 URL
|
||||
* @param {Object} options - fetch 选项
|
||||
* @returns {Promise<any>} 响应数据
|
||||
*/
|
||||
async function apiRequest(url, options = {}) {
|
||||
// 如果 URL 以 /api 开头,去掉它(因为 apiClient.request 会自动添加)
|
||||
const endpoint = url.startsWith('/api') ? url.slice(4) : url;
|
||||
return apiClient.request(endpoint, options);
|
||||
}
|
||||
|
||||
// 导出所有工具函数
|
||||
export {
|
||||
formatUptime,
|
||||
|
|
@ -282,5 +295,6 @@ export {
|
|||
showToast,
|
||||
getFieldLabel,
|
||||
getProviderTypeFields,
|
||||
getProviderStats
|
||||
getProviderStats,
|
||||
apiRequest
|
||||
};
|
||||
|
|
@ -60,6 +60,9 @@
|
|||
<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>
|
||||
|
|
@ -965,6 +968,73 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in a new issue