mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-02-04 06:31:13 +00:00
Improve plugin status handling and dirname lookup
Enhanced setPluginStatus to support enabling/disabling plugins by both package name and dirname, improving robustness when plugins are not loaded. Also removed redundant directory name matching logic from findDirnameById in the web UI backend. Register plugin after installation in PluginStore Adds logic to immediately register a plugin with the plugin manager after installation, both in the standard and SSE install handlers. This ensures newly installed plugins are available without requiring a restart or manual reload. Refactor plugin path handling in plugin manager Simplifies plugin directory and data path resolution by using pluginPath from the plugin context instead of fileId. Streamlines plugin uninstall and reload logic, removing redundant file system scans and improving code clarity. Refactor plugin API to use package id and improve UX Standardized plugin management APIs and frontend to use 'id' (package name) instead of ambiguous 'name' or 'filename'. Added support for a 'plugin' display field in package.json and improved plugin store UI to show install/update status. Refactored backend and frontend logic for enabling, disabling, uninstalling, and configuring plugins to use consistent identifiers, and enhanced type definitions and documentation for better maintainability.
This commit is contained in:
parent
7f05aee11d
commit
699b46acbd
@ -9,6 +9,7 @@ import path from 'path';
|
||||
|
||||
export interface PluginPackageJson {
|
||||
name?: string;
|
||||
plugin?: string;
|
||||
version?: string;
|
||||
main?: string;
|
||||
description?: string;
|
||||
@ -106,7 +107,7 @@ export interface PluginModule<T extends OB11EmitEventContent = OB11EmitEventCont
|
||||
|
||||
export interface LoadedPlugin {
|
||||
name: string;
|
||||
dirname: string; // Actual directory name for path resolution
|
||||
fileId: string; // 文件系统目录名,用于路径解析
|
||||
version?: string;
|
||||
pluginPath: string;
|
||||
entryPath: string;
|
||||
@ -122,14 +123,15 @@ export interface PluginStatusConfig {
|
||||
export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
private readonly pluginPath: string;
|
||||
private readonly configPath: string;
|
||||
private loadedPlugins: Map<string, LoadedPlugin> = new Map();
|
||||
// Track failed plugins: ID -> Error Message
|
||||
/** 插件注册表: 包名(id) -> 插件数据 */
|
||||
private pluginRegistry: Map<string, LoadedPlugin> = new Map();
|
||||
/** 失败的插件: ID -> 错误信息 */
|
||||
private failedPlugins: Map<string, string> = new Map();
|
||||
declare config: PluginConfig;
|
||||
public NapCatConfig = NapCatConfig;
|
||||
|
||||
override get isActive (): boolean {
|
||||
return this.isEnable && this.loadedPlugins.size > 0;
|
||||
return this.isEnable && this.pluginRegistry.size > 0;
|
||||
}
|
||||
|
||||
constructor (
|
||||
@ -192,9 +194,19 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
continue;
|
||||
}
|
||||
|
||||
const pluginId = item.name;
|
||||
// 先读取 package.json 获取包名(id)
|
||||
const packageJsonPath = path.join(this.pluginPath, item.name, 'package.json');
|
||||
let pluginId = item.name; // 默认使用目录名
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
|
||||
if (pkg.name) {
|
||||
pluginId = pkg.name;
|
||||
}
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
// Check if plugin is disabled in config
|
||||
// Check if plugin is disabled in config (by id)
|
||||
if (pluginConfig[pluginId] === false) {
|
||||
this.logger.log(`[Plugin Adapter] Plugin ${pluginId} is disabled in config, skipping`);
|
||||
continue;
|
||||
@ -205,7 +217,7 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`[Plugin Adapter] Loaded ${this.loadedPlugins.size} plugins`
|
||||
`[Plugin Adapter] Loaded ${this.pluginRegistry.size} plugins`
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.logError('[Plugin Adapter] Error loading plugins:', error);
|
||||
@ -219,13 +231,6 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
*/
|
||||
public async loadDirectoryPlugin (dirname: string): Promise<void> {
|
||||
const pluginDir = path.join(this.pluginPath, dirname);
|
||||
const pluginConfig = this.loadPluginConfig();
|
||||
const pluginId = dirname; // Use directory name as unique ID
|
||||
|
||||
if (pluginConfig[pluginId] === false) {
|
||||
this.logger.log(`[Plugin Adapter] Plugin ${pluginId} is disabled by user`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 尝试读取 package.json
|
||||
@ -244,6 +249,16 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
}
|
||||
}
|
||||
|
||||
// 获取插件 id(包名)
|
||||
const pluginId = packageJson?.name || dirname;
|
||||
|
||||
// 检查插件是否被禁用 (by id)
|
||||
const pluginConfig = this.loadPluginConfig();
|
||||
if (pluginConfig[pluginId] === false) {
|
||||
this.logger.log(`[Plugin Adapter] Plugin ${pluginId} is disabled by user`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 确定入口文件
|
||||
const entryFile = this.findEntryFile(pluginDir, packageJson);
|
||||
if (!entryFile) {
|
||||
@ -264,8 +279,8 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
}
|
||||
|
||||
const plugin: LoadedPlugin = {
|
||||
name: packageJson?.name || pluginId, // Use package.json name for API lookups, fallback to dir name
|
||||
dirname: pluginId, // Keep track of actual directory name for path resolution
|
||||
name: pluginId, // 使用包名作为 id
|
||||
fileId: dirname, // 保留目录名用于路径解析
|
||||
version: packageJson?.version,
|
||||
pluginPath: pluginDir,
|
||||
entryPath,
|
||||
@ -329,15 +344,12 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
return module && typeof module.plugin_init === 'function';
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册插件
|
||||
*/
|
||||
/**
|
||||
* 注册插件
|
||||
*/
|
||||
private async registerPlugin (plugin: LoadedPlugin): Promise<void> {
|
||||
// 检查名称冲突
|
||||
if (this.loadedPlugins.has(plugin.name)) {
|
||||
if (this.pluginRegistry.has(plugin.name)) {
|
||||
this.logger.logWarn(
|
||||
`[Plugin Adapter] Plugin name conflict: ${plugin.name}, skipping...`
|
||||
);
|
||||
@ -345,8 +357,7 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
}
|
||||
|
||||
// Create Context
|
||||
// Use dirname for path resolution, name for identification
|
||||
const dataPath = path.join(this.pluginPath, plugin.dirname, 'data');
|
||||
const dataPath = path.join(plugin.pluginPath, 'data');
|
||||
const configPath = path.join(dataPath, 'config.json');
|
||||
|
||||
// Create plugin-specific logger with prefix
|
||||
@ -376,7 +387,9 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
|
||||
plugin.context = context; // Store context on plugin object
|
||||
|
||||
this.loadedPlugins.set(plugin.name, plugin);
|
||||
// 注册到映射表
|
||||
this.pluginRegistry.set(plugin.name, plugin);
|
||||
|
||||
this.logger.log(
|
||||
`[Plugin Adapter] Registered plugin: ${plugin.name}${plugin.version ? ` v${plugin.version}` : ''
|
||||
}`
|
||||
@ -393,7 +406,7 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
);
|
||||
// Mark as failed
|
||||
this.failedPlugins.set(plugin.name, error.message || 'Initialization failed');
|
||||
this.loadedPlugins.delete(plugin.name);
|
||||
this.pluginRegistry.delete(plugin.name);
|
||||
}
|
||||
}
|
||||
|
||||
@ -401,7 +414,7 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
* 卸载插件
|
||||
*/
|
||||
private async unloadPlugin (pluginName: string): Promise<void> {
|
||||
const plugin = this.loadedPlugins.get(pluginName);
|
||||
const plugin = this.pluginRegistry.get(pluginName);
|
||||
if (!plugin) {
|
||||
return;
|
||||
}
|
||||
@ -419,7 +432,8 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
}
|
||||
}
|
||||
|
||||
this.loadedPlugins.delete(pluginName);
|
||||
// 从映射表中移除
|
||||
this.pluginRegistry.delete(pluginName);
|
||||
this.logger.log(`[Plugin Adapter] Unloaded plugin: ${pluginName}`);
|
||||
}
|
||||
|
||||
@ -435,19 +449,22 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
return this.loadPluginConfig();
|
||||
}
|
||||
|
||||
public setPluginStatus (pluginName: string, enable: boolean): void {
|
||||
// Try to find plugin by package name first
|
||||
const plugin = this.loadedPlugins.get(pluginName);
|
||||
// Use dirname for config storage (if plugin is loaded), otherwise assume pluginName is dirname
|
||||
const configKey = plugin?.dirname || pluginName;
|
||||
|
||||
/**
|
||||
* 设置插件状态(启用/禁用)
|
||||
* @param pluginId 插件包名(id)
|
||||
* @param enable 是否启用
|
||||
*/
|
||||
public setPluginStatus (pluginId: string, enable: boolean): void {
|
||||
const config = this.loadPluginConfig();
|
||||
config[configKey] = enable;
|
||||
config[pluginId] = enable;
|
||||
this.savePluginConfig(config);
|
||||
|
||||
if (!enable && plugin) {
|
||||
// Unload by plugin.name (package name, which is the key in loadedPlugins)
|
||||
this.unloadPlugin(plugin.name).catch(e => this.logger.logError('Error unloading', e));
|
||||
// 如果禁用且插件已加载,则卸载
|
||||
if (!enable) {
|
||||
const plugin = this.pluginRegistry.get(pluginId);
|
||||
if (plugin) {
|
||||
this.unloadPlugin(pluginId).catch(e => this.logger.logError('Error unloading', e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -458,7 +475,7 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
|
||||
try {
|
||||
await Promise.allSettled(
|
||||
Array.from(this.loadedPlugins.values()).map((plugin) =>
|
||||
Array.from(this.pluginRegistry.values()).map((plugin) =>
|
||||
this.callPluginEventHandler(plugin, event)
|
||||
)
|
||||
);
|
||||
@ -513,7 +530,7 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
await this.loadPlugins();
|
||||
|
||||
this.logger.log(
|
||||
`[Plugin Adapter] Plugin adapter opened with ${this.loadedPlugins.size} plugins loaded`
|
||||
`[Plugin Adapter] Plugin adapter opened with ${this.pluginRegistry.size} plugins loaded`
|
||||
);
|
||||
}
|
||||
|
||||
@ -526,7 +543,7 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
this.isEnable = false;
|
||||
|
||||
// 卸载所有插件
|
||||
const pluginNames = Array.from(this.loadedPlugins.keys());
|
||||
const pluginNames = Array.from(this.pluginRegistry.keys());
|
||||
for (const pluginName of pluginNames) {
|
||||
await this.unloadPlugin(pluginName);
|
||||
}
|
||||
@ -549,55 +566,119 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
* 获取已加载的插件列表
|
||||
*/
|
||||
public getLoadedPlugins (): LoadedPlugin[] {
|
||||
return Array.from(this.loadedPlugins.values());
|
||||
return Array.from(this.pluginRegistry.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件信息
|
||||
* 通过包名(id)获取插件信息
|
||||
*/
|
||||
public getPluginInfo (pluginName: string): LoadedPlugin | undefined {
|
||||
return this.loadedPlugins.get(pluginName);
|
||||
public getPluginInfo (pluginId: string): LoadedPlugin | undefined {
|
||||
return this.pluginRegistry.get(pluginId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 id 加载插件
|
||||
*/
|
||||
public async loadPluginById (id: string): Promise<boolean> {
|
||||
// 扫描文件系统查找 fileId
|
||||
if (!fs.existsSync(this.pluginPath)) {
|
||||
this.logger.logWarn(`[Plugin Adapter] Plugin ${id} not found in filesystem`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const items = fs.readdirSync(this.pluginPath, { withFileTypes: true });
|
||||
for (const item of items) {
|
||||
if (!item.isDirectory()) continue;
|
||||
|
||||
const packageJsonPath = path.join(this.pluginPath, item.name, 'package.json');
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
|
||||
if (pkg.name === id) {
|
||||
await this.loadDirectoryPlugin(item.name);
|
||||
return this.pluginRegistry.has(id);
|
||||
}
|
||||
} catch (e) { }
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.logWarn(`[Plugin Adapter] Plugin ${id} not found in filesystem`);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 卸载并删除插件
|
||||
*/
|
||||
public async uninstallPlugin (id: string, cleanData: boolean = false): Promise<void> {
|
||||
const plugin = this.pluginRegistry.get(id);
|
||||
if (!plugin) {
|
||||
throw new Error(`Plugin ${id} not found or not loaded`);
|
||||
}
|
||||
|
||||
const pluginPath = plugin.context.pluginPath;
|
||||
const dataPath = plugin.context.dataPath;
|
||||
|
||||
// 先卸载插件
|
||||
await this.unloadPlugin(id);
|
||||
|
||||
// 删除插件目录
|
||||
if (fs.existsSync(pluginPath)) {
|
||||
fs.rmSync(pluginPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// 清理数据
|
||||
if (cleanData && fs.existsSync(dataPath)) {
|
||||
fs.rmSync(dataPath, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重载指定插件
|
||||
*/
|
||||
public async reloadPlugin (pluginName: string): Promise<boolean> {
|
||||
const plugin = this.loadedPlugins.get(pluginName);
|
||||
public async reloadPlugin (pluginId: string): Promise<boolean> {
|
||||
const plugin = this.pluginRegistry.get(pluginId);
|
||||
if (!plugin) {
|
||||
this.logger.logWarn(`[Plugin Adapter] Plugin ${pluginName} not found`);
|
||||
this.logger.logWarn(`[Plugin Adapter] Plugin ${pluginId} not found`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const dirname = plugin.dirname;
|
||||
const dirname = path.basename(plugin.pluginPath);
|
||||
|
||||
try {
|
||||
// 卸载插件
|
||||
await this.unloadPlugin(pluginName);
|
||||
await this.unloadPlugin(pluginId);
|
||||
|
||||
// 重新加载插件 - use dirname for directory loading
|
||||
// 重新加载插件
|
||||
await this.loadDirectoryPlugin(dirname);
|
||||
|
||||
this.logger.log(
|
||||
`[Plugin Adapter] Plugin ${pluginName} reloaded successfully`
|
||||
`[Plugin Adapter] Plugin ${pluginId} reloaded successfully`
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logger.logError(
|
||||
`[Plugin Adapter] Error reloading plugin ${pluginName}:`,
|
||||
`[Plugin Adapter] Error reloading plugin ${pluginId}:`,
|
||||
error
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
public getPluginDataPath (pluginName: string): string {
|
||||
// Lookup plugin by name (package name) and use dirname for path
|
||||
const plugin = this.loadedPlugins.get(pluginName);
|
||||
const dirname = plugin?.dirname || pluginName; // fallback to pluginName if not found
|
||||
return path.join(this.pluginPath, dirname, 'data');
|
||||
|
||||
/**
|
||||
* 获取插件数据目录路径
|
||||
*/
|
||||
public getPluginDataPath (pluginId: string): string {
|
||||
const plugin = this.pluginRegistry.get(pluginId);
|
||||
if (!plugin) {
|
||||
throw new Error(`Plugin ${pluginId} not found`);
|
||||
}
|
||||
return plugin.context.dataPath;
|
||||
}
|
||||
|
||||
public getPluginConfigPath (pluginName: string): string {
|
||||
return path.join(this.getPluginDataPath(pluginName), 'config.json');
|
||||
/**
|
||||
* 获取插件配置文件路径
|
||||
*/
|
||||
public getPluginConfigPath (pluginId: string): string {
|
||||
return path.join(this.getPluginDataPath(pluginId), 'config.json');
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,6 +9,7 @@ import path from 'path';
|
||||
|
||||
export interface PluginPackageJson {
|
||||
name?: string;
|
||||
plugin?: string;
|
||||
version?: string;
|
||||
main?: string;
|
||||
}
|
||||
@ -255,7 +256,7 @@ export class OB11PluginAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
this.logger.log(`[Plugin Adapter] Unloaded plugin: ${pluginName}`);
|
||||
}
|
||||
|
||||
async onEvent<T extends OB11EmitEventContent>(event: T) {
|
||||
async onEvent<T extends OB11EmitEventContent> (event: T) {
|
||||
if (!this.isEnable) {
|
||||
return;
|
||||
}
|
||||
@ -357,7 +358,7 @@ export class OB11PluginAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
|
||||
// 重新加载插件
|
||||
const isDirectory = fs.statSync(plugin.pluginPath).isDirectory() &&
|
||||
plugin.pluginPath !== this.pluginPath;
|
||||
plugin.pluginPath !== this.pluginPath;
|
||||
|
||||
if (isDirectory) {
|
||||
const dirname = path.basename(plugin.pluginPath);
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
{
|
||||
"name": "napcat-plugin-builtin",
|
||||
"plugin": "内置插件",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "index.mjs",
|
||||
|
||||
@ -64,32 +64,18 @@ export const GetPluginListHandler: RequestHandler = async (_req, res) => {
|
||||
return sendSuccess(res, { plugins: [], pluginManagerNotFound: true });
|
||||
}
|
||||
|
||||
// 辅助函数:根据文件名/路径生成唯一ID(作为配置键)
|
||||
const getPluginId = (fsName: string, isFile: boolean): string => {
|
||||
if (isFile) {
|
||||
return path.parse(fsName).name;
|
||||
}
|
||||
return fsName;
|
||||
};
|
||||
|
||||
const loadedPlugins = pluginManager.getLoadedPlugins();
|
||||
const loadedPluginMap = new Map<string, any>(); // Map ID -> Loaded Info
|
||||
const loadedPluginMap = new Map<string, any>(); // Map id -> Loaded Info
|
||||
|
||||
// 1. 整理已加载的插件
|
||||
for (const p of loadedPlugins) {
|
||||
// Use dirname for map key (matches filesystem scan)
|
||||
const id = p.dirname;
|
||||
const fsName = p.dirname; // dirname is the actual filesystem directory name
|
||||
|
||||
loadedPluginMap.set(id, {
|
||||
name: p.name, // This is now package name (from packageJson.name || dirname)
|
||||
id: id,
|
||||
loadedPluginMap.set(p.name, {
|
||||
name: p.packageJson?.plugin || p.name, // 优先显示 package.json 的 plugin 字段
|
||||
id: p.name, // 包名,用于 API 操作
|
||||
version: p.version || '0.0.0',
|
||||
description: p.packageJson?.description || '',
|
||||
author: p.packageJson?.author || '',
|
||||
status: 'active',
|
||||
filename: fsName, // 真实文件/目录名
|
||||
loadedName: p.name, // 运行时注册的名称,用于重载/卸载 (package name)
|
||||
hasConfig: !!(p.module.plugin_config_schema || p.module.plugin_config_ui)
|
||||
});
|
||||
}
|
||||
@ -103,15 +89,25 @@ export const GetPluginListHandler: RequestHandler = async (_req, res) => {
|
||||
const items = fs.readdirSync(pluginPath, { withFileTypes: true });
|
||||
|
||||
for (const item of items) {
|
||||
let id = '';
|
||||
if (!item.isDirectory()) continue;
|
||||
|
||||
if (item.isFile()) {
|
||||
if (!['.js', '.mjs'].includes(path.extname(item.name))) continue;
|
||||
id = getPluginId(item.name, true);
|
||||
} else if (item.isDirectory()) {
|
||||
id = getPluginId(item.name, false);
|
||||
} else {
|
||||
continue;
|
||||
// 读取 package.json 获取插件信息
|
||||
let id = item.name;
|
||||
let name = item.name;
|
||||
let version = '0.0.0';
|
||||
let description = '';
|
||||
let author = '';
|
||||
|
||||
const packageJsonPath = path.join(pluginPath, item.name, 'package.json');
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
|
||||
id = pkg.name || id;
|
||||
name = pkg.plugin || pkg.name || name;
|
||||
version = pkg.version || version;
|
||||
description = pkg.description || description;
|
||||
author = pkg.author || author;
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
const isActiveConfig = pluginConfig[id] !== false; // 默认为 true
|
||||
@ -121,37 +117,14 @@ export const GetPluginListHandler: RequestHandler = async (_req, res) => {
|
||||
const loadedInfo = loadedPluginMap.get(id);
|
||||
allPlugins.push(loadedInfo);
|
||||
} else {
|
||||
// 未加载 (可能是被禁用,或者加载失败,或者新增未运行)
|
||||
let version = '0.0.0';
|
||||
let description = '';
|
||||
let author = '';
|
||||
// 默认显示名称为 ID (文件名/目录名)
|
||||
let name = id;
|
||||
|
||||
try {
|
||||
// 尝试读取 package.json 获取信息
|
||||
if (item.isDirectory()) {
|
||||
const packageJsonPath = path.join(pluginPath, item.name, 'package.json');
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
|
||||
version = pkg.version || version;
|
||||
description = pkg.description || description;
|
||||
author = pkg.author || author;
|
||||
// 如果 package.json 有 name,优先使用
|
||||
name = pkg.name || name;
|
||||
}
|
||||
}
|
||||
} catch (e) { }
|
||||
|
||||
allPlugins.push({
|
||||
name: name,
|
||||
id: id,
|
||||
name,
|
||||
id,
|
||||
version,
|
||||
description,
|
||||
author,
|
||||
// 如果配置是 false,则为 disabled;否则是 stopped (应启动但未启动)
|
||||
status: isActiveConfig ? 'stopped' : 'disabled',
|
||||
filename: item.name
|
||||
status: isActiveConfig ? 'stopped' : 'disabled'
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -160,43 +133,27 @@ export const GetPluginListHandler: RequestHandler = async (_req, res) => {
|
||||
return sendSuccess(res, { plugins: allPlugins, pluginManagerNotFound: false });
|
||||
};
|
||||
|
||||
// ReloadPluginHandler removed
|
||||
|
||||
export const SetPluginStatusHandler: RequestHandler = async (req, res) => {
|
||||
const { enable, filename, name } = req.body;
|
||||
// filename is the directory name (used for fs checks)
|
||||
// name is the package name (used for plugin manager API, if provided)
|
||||
// We need to determine: which to use for setPluginStatus call
|
||||
const { enable, id } = req.body;
|
||||
|
||||
if (!filename && !name) return sendError(res, 'Plugin Filename or Name is required');
|
||||
if (!id) return sendError(res, 'Plugin id is required');
|
||||
|
||||
const pluginManager = getPluginManager();
|
||||
if (!pluginManager) {
|
||||
return sendError(res, 'Plugin Manager not found');
|
||||
}
|
||||
|
||||
// Determine which ID to use
|
||||
// If 'name' (package name) is provided, use it for pluginManager calls
|
||||
// But 'filename' (dirname) is needed for filesystem operations
|
||||
const dirname = filename || name; // fallback
|
||||
const pluginName = name || filename; // fallback
|
||||
|
||||
try {
|
||||
// setPluginStatus now handles both package name and dirname lookup internally
|
||||
pluginManager.setPluginStatus(pluginName, enable);
|
||||
// 设置插件状态
|
||||
pluginManager.setPluginStatus(id, enable);
|
||||
|
||||
// If enabling, trigger load
|
||||
// 如果启用,需要加载插件
|
||||
if (enable) {
|
||||
const pluginPath = pluginManager.getPluginPath();
|
||||
const fullPath = path.join(pluginPath, dirname);
|
||||
|
||||
if (fs.existsSync(fullPath) && fs.statSync(fullPath).isDirectory()) {
|
||||
await pluginManager.loadDirectoryPlugin(dirname);
|
||||
} else {
|
||||
return sendError(res, 'Plugin directory not found: ' + dirname);
|
||||
const loaded = await pluginManager.loadPluginById(id);
|
||||
if (!loaded) {
|
||||
return sendError(res, 'Plugin not found: ' + id);
|
||||
}
|
||||
}
|
||||
// Disabling is handled by setPluginStatus
|
||||
|
||||
return sendSuccess(res, { message: 'Status updated successfully' });
|
||||
} catch (e: any) {
|
||||
@ -205,47 +162,17 @@ export const SetPluginStatusHandler: RequestHandler = async (req, res) => {
|
||||
};
|
||||
|
||||
export const UninstallPluginHandler: RequestHandler = async (req, res) => {
|
||||
const { name, filename, cleanData } = req.body;
|
||||
// If it's loaded, we use name. If it's disabled, we might use filename.
|
||||
const { id, cleanData } = req.body;
|
||||
|
||||
if (!id) return sendError(res, 'Plugin id is required');
|
||||
|
||||
const pluginManager = getPluginManager();
|
||||
if (!pluginManager) {
|
||||
return sendError(res, 'Plugin Manager not found');
|
||||
}
|
||||
|
||||
// Check if loaded
|
||||
const plugin = pluginManager.getPluginInfo(name);
|
||||
let fsPath = '';
|
||||
|
||||
if (plugin) {
|
||||
// Active plugin
|
||||
await pluginManager.unregisterPlugin(name);
|
||||
if (plugin.pluginPath === pluginManager.getPluginPath()) {
|
||||
fsPath = plugin.entryPath;
|
||||
} else {
|
||||
fsPath = plugin.pluginPath;
|
||||
}
|
||||
} else {
|
||||
// Disabled or not loaded
|
||||
if (filename) {
|
||||
fsPath = path.join(pluginManager.getPluginPath(), filename);
|
||||
} else {
|
||||
return sendError(res, 'Plugin not found, provide filename if disabled');
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (fs.existsSync(fsPath)) {
|
||||
fs.rmSync(fsPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
if (cleanData && name) {
|
||||
const dataPath = pluginManager.getPluginDataPath(name);
|
||||
if (fs.existsSync(dataPath)) {
|
||||
fs.rmSync(dataPath, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
await pluginManager.uninstallPlugin(id, cleanData);
|
||||
return sendSuccess(res, { message: 'Uninstalled successfully' });
|
||||
} catch (e: any) {
|
||||
return sendError(res, 'Failed to uninstall: ' + e.message);
|
||||
@ -253,13 +180,13 @@ export const UninstallPluginHandler: RequestHandler = async (req, res) => {
|
||||
};
|
||||
|
||||
export const GetPluginConfigHandler: RequestHandler = async (req, res) => {
|
||||
const name = req.query['name'] as string;
|
||||
if (!name) return sendError(res, 'Plugin Name is required');
|
||||
const id = req.query['id'] as string;
|
||||
if (!id) return sendError(res, 'Plugin id is required');
|
||||
|
||||
const pluginManager = getPluginManager();
|
||||
if (!pluginManager) return sendError(res, 'Plugin Manager not found');
|
||||
|
||||
const plugin = pluginManager.getPluginInfo(name);
|
||||
const plugin = pluginManager.getPluginInfo(id);
|
||||
if (!plugin) return sendError(res, 'Plugin not loaded');
|
||||
|
||||
// Support legacy schema or new API
|
||||
@ -274,7 +201,7 @@ export const GetPluginConfigHandler: RequestHandler = async (req, res) => {
|
||||
// Default behavior: read from default config path
|
||||
try {
|
||||
// Use context configPath if available
|
||||
const configPath = plugin.context?.configPath || pluginManager.getPluginConfigPath(name);
|
||||
const configPath = plugin.context?.configPath || pluginManager.getPluginConfigPath(id);
|
||||
if (fs.existsSync(configPath)) {
|
||||
config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
}
|
||||
@ -285,13 +212,13 @@ export const GetPluginConfigHandler: RequestHandler = async (req, res) => {
|
||||
};
|
||||
|
||||
export const SetPluginConfigHandler: RequestHandler = async (req, res) => {
|
||||
const { name, config } = req.body;
|
||||
if (!name || !config) return sendError(res, 'Name and Config required');
|
||||
const { id, config } = req.body;
|
||||
if (!id || !config) return sendError(res, 'Plugin id and config required');
|
||||
|
||||
const pluginManager = getPluginManager();
|
||||
if (!pluginManager) return sendError(res, 'Plugin Manager not found');
|
||||
|
||||
const plugin = pluginManager.getPluginInfo(name);
|
||||
const plugin = pluginManager.getPluginInfo(id);
|
||||
if (!plugin) return sendError(res, 'Plugin not loaded');
|
||||
|
||||
if (plugin.module.plugin_set_config) {
|
||||
@ -304,7 +231,7 @@ export const SetPluginConfigHandler: RequestHandler = async (req, res) => {
|
||||
} else if (plugin.module.plugin_config_schema || plugin.module.plugin_config_ui) {
|
||||
// Default behavior: write to default config path
|
||||
try {
|
||||
const configPath = plugin.context?.configPath || pluginManager.getPluginConfigPath(name);
|
||||
const configPath = plugin.context?.configPath || pluginManager.getPluginConfigPath(id);
|
||||
|
||||
const configDir = path.dirname(configPath);
|
||||
if (!fs.existsSync(configDir)) {
|
||||
@ -313,7 +240,7 @@ export const SetPluginConfigHandler: RequestHandler = async (req, res) => {
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
||||
|
||||
// Auto-Reload plugin to apply changes
|
||||
await pluginManager.reloadPlugin(name);
|
||||
await pluginManager.reloadPlugin(id);
|
||||
|
||||
return sendSuccess(res, { message: 'Config saved and plugin reloaded' });
|
||||
} catch (e: any) {
|
||||
|
||||
@ -8,6 +8,16 @@ import { createWriteStream } from 'fs';
|
||||
import compressing from 'compressing';
|
||||
import { findAvailableDownloadUrl, GITHUB_RAW_MIRRORS } from 'napcat-common/src/mirror';
|
||||
import { webUiPathWrapper } from '@/napcat-webui-backend/index';
|
||||
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
|
||||
import { NapCatOneBot11Adapter } from '@/napcat-onebot/index';
|
||||
import { OB11PluginMangerAdapter } from '@/napcat-onebot/network/plugin-manger';
|
||||
|
||||
// Helper to get the plugin manager adapter
|
||||
const getPluginManager = (): OB11PluginMangerAdapter | null => {
|
||||
const ob11 = WebUiDataRuntime.getOneBotContext() as NapCatOneBot11Adapter;
|
||||
if (!ob11) return null;
|
||||
return ob11.networkManager.findSomeAdapter('plugin_manager') as OB11PluginMangerAdapter;
|
||||
};
|
||||
|
||||
// 插件商店源配置
|
||||
const PLUGIN_STORE_SOURCES = [
|
||||
@ -242,6 +252,15 @@ export const InstallPluginFromStoreHandler: RequestHandler = async (req, res) =>
|
||||
// 删除临时文件
|
||||
fs.unlinkSync(tempZipPath);
|
||||
|
||||
// 如果 pluginManager 存在,立即注册插件
|
||||
const pluginManager = getPluginManager();
|
||||
if (pluginManager) {
|
||||
// 检查是否已注册,避免重复注册
|
||||
if (!pluginManager.getPluginInfo(id)) {
|
||||
await pluginManager.loadPluginById(id);
|
||||
}
|
||||
}
|
||||
|
||||
return sendSuccess(res, {
|
||||
message: 'Plugin installed successfully',
|
||||
plugin: plugin,
|
||||
@ -315,6 +334,16 @@ export const InstallPluginFromStoreSSEHandler: RequestHandler = async (req, res)
|
||||
sendProgress('解压完成,正在清理...', 90);
|
||||
fs.unlinkSync(tempZipPath);
|
||||
|
||||
// 如果 pluginManager 存在,立即注册插件
|
||||
const pluginManager = getPluginManager();
|
||||
if (pluginManager) {
|
||||
// 检查是否已注册,避免重复注册
|
||||
if (!pluginManager.getPluginInfo(id)) {
|
||||
sendProgress('正在注册插件...', 95);
|
||||
await pluginManager.loadPluginById(id);
|
||||
}
|
||||
}
|
||||
|
||||
sendProgress('安装成功!', 100);
|
||||
res.write(`data: ${JSON.stringify({
|
||||
success: true,
|
||||
|
||||
@ -1,19 +1,25 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Chip } from '@heroui/chip';
|
||||
import { useState } from 'react';
|
||||
import { IoMdDownload } from 'react-icons/io';
|
||||
import { IoMdDownload, IoMdRefresh, IoMdCheckmarkCircle } from 'react-icons/io';
|
||||
|
||||
import DisplayCardContainer from './container';
|
||||
import { PluginStoreItem } from '@/types/plugin-store';
|
||||
|
||||
export type InstallStatus = 'not-installed' | 'installed' | 'update-available';
|
||||
|
||||
export interface PluginStoreCardProps {
|
||||
data: PluginStoreItem;
|
||||
onInstall: () => Promise<void>;
|
||||
installStatus?: InstallStatus;
|
||||
installedVersion?: string;
|
||||
}
|
||||
|
||||
const PluginStoreCard: React.FC<PluginStoreCardProps> = ({
|
||||
data,
|
||||
onInstall,
|
||||
installStatus = 'not-installed',
|
||||
installedVersion,
|
||||
}) => {
|
||||
const { name, version, author, description, tags, id } = data;
|
||||
const [processing, setProcessing] = useState(false);
|
||||
@ -23,19 +29,65 @@ const PluginStoreCard: React.FC<PluginStoreCardProps> = ({
|
||||
onInstall().finally(() => setProcessing(false));
|
||||
};
|
||||
|
||||
// 根据安装状态返回按钮配置
|
||||
const getButtonConfig = () => {
|
||||
switch (installStatus) {
|
||||
case 'installed':
|
||||
return {
|
||||
text: '重新安装',
|
||||
icon: <IoMdRefresh size={16} />,
|
||||
color: 'default' as const,
|
||||
};
|
||||
case 'update-available':
|
||||
return {
|
||||
text: '更新',
|
||||
icon: <IoMdDownload size={16} />,
|
||||
color: 'success' as const,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
text: '安装',
|
||||
icon: <IoMdDownload size={16} />,
|
||||
color: 'primary' as const,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const buttonConfig = getButtonConfig();
|
||||
|
||||
return (
|
||||
<DisplayCardContainer
|
||||
className='w-full max-w-[420px]'
|
||||
title={name}
|
||||
tag={
|
||||
<Chip
|
||||
className="ml-auto"
|
||||
color="primary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
v{version}
|
||||
</Chip>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
{installStatus === 'installed' && (
|
||||
<Chip
|
||||
color="success"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
startContent={<IoMdCheckmarkCircle size={14} />}
|
||||
>
|
||||
已安装
|
||||
</Chip>
|
||||
)}
|
||||
{installStatus === 'update-available' && (
|
||||
<Chip
|
||||
color="warning"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
可更新
|
||||
</Chip>
|
||||
)}
|
||||
<Chip
|
||||
color="primary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
v{version}
|
||||
</Chip>
|
||||
</div>
|
||||
}
|
||||
enableSwitch={undefined}
|
||||
action={
|
||||
@ -43,13 +95,13 @@ const PluginStoreCard: React.FC<PluginStoreCardProps> = ({
|
||||
fullWidth
|
||||
radius='full'
|
||||
size='sm'
|
||||
color='primary'
|
||||
startContent={<IoMdDownload size={16} />}
|
||||
color={buttonConfig.color}
|
||||
startContent={buttonConfig.icon}
|
||||
onPress={handleInstall}
|
||||
isLoading={processing}
|
||||
isDisabled={processing}
|
||||
>
|
||||
安装
|
||||
{buttonConfig.text}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
|
||||
@ -1,21 +1,34 @@
|
||||
import { serverRequest } from '@/utils/request';
|
||||
import { PluginStoreList, PluginStoreItem } from '@/types/plugin-store';
|
||||
|
||||
/** 插件状态 */
|
||||
export type PluginStatus = 'active' | 'disabled' | 'stopped';
|
||||
|
||||
/** 插件信息 */
|
||||
export interface PluginItem {
|
||||
/** 显示名称 (优先 package.json 的 plugin 字段) */
|
||||
name: string;
|
||||
/** 包名 (package name),用于 API 操作 */
|
||||
id: string;
|
||||
/** 版本号 */
|
||||
version: string;
|
||||
/** 描述 */
|
||||
description: string;
|
||||
/** 作者 */
|
||||
author: string;
|
||||
status: 'active' | 'disabled' | 'stopped';
|
||||
filename?: string;
|
||||
/** 状态: active-运行中, disabled-已禁用, stopped-已停止 */
|
||||
status: PluginStatus;
|
||||
/** 是否有配置项 */
|
||||
hasConfig?: boolean;
|
||||
}
|
||||
|
||||
/** 插件列表响应 */
|
||||
export interface PluginListResponse {
|
||||
plugins: PluginItem[];
|
||||
pluginManagerNotFound: boolean;
|
||||
}
|
||||
|
||||
/** 插件配置项定义 */
|
||||
export interface PluginConfigSchemaItem {
|
||||
key: string;
|
||||
type: 'string' | 'number' | 'boolean' | 'select' | 'multi-select' | 'html' | 'text';
|
||||
@ -26,19 +39,27 @@ export interface PluginConfigSchemaItem {
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
/** 插件配置响应 */
|
||||
export interface PluginConfigResponse {
|
||||
schema: PluginConfigSchemaItem[];
|
||||
config: any;
|
||||
config: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** 服务端响应 */
|
||||
export interface ServerResponse<T> {
|
||||
code: number;
|
||||
message: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件管理器 API
|
||||
*/
|
||||
export default class PluginManager {
|
||||
public static async getPluginList () {
|
||||
/**
|
||||
* 获取插件列表
|
||||
*/
|
||||
public static async getPluginList (): Promise<PluginListResponse> {
|
||||
const { data } = await serverRequest.get<ServerResponse<PluginListResponse>>('/Plugin/List');
|
||||
return data.data;
|
||||
}
|
||||
@ -46,46 +67,80 @@ export default class PluginManager {
|
||||
/**
|
||||
* 手动注册插件管理器到 NetworkManager
|
||||
*/
|
||||
public static async registerPluginManager () {
|
||||
public static async registerPluginManager (): Promise<{ message: string; }> {
|
||||
const { data } = await serverRequest.post<ServerResponse<{ message: string; }>>('/Plugin/RegisterManager');
|
||||
return data.data;
|
||||
}
|
||||
|
||||
|
||||
|
||||
public static async setPluginStatus (name: string, enable: boolean, filename?: string) {
|
||||
await serverRequest.post<ServerResponse<void>>('/Plugin/SetStatus', { name, enable, filename });
|
||||
/**
|
||||
* 设置插件状态(启用/禁用)
|
||||
* @param id 插件包名
|
||||
* @param enable 是否启用
|
||||
*/
|
||||
public static async setPluginStatus (id: string, enable: boolean): Promise<void> {
|
||||
await serverRequest.post<ServerResponse<void>>('/Plugin/SetStatus', { id, enable });
|
||||
}
|
||||
|
||||
public static async uninstallPlugin (name: string, filename?: string, cleanData?: boolean) {
|
||||
await serverRequest.post<ServerResponse<void>>('/Plugin/Uninstall', { name, filename, cleanData });
|
||||
/**
|
||||
* 卸载插件
|
||||
* @param id 插件包名
|
||||
* @param cleanData 是否清理数据
|
||||
*/
|
||||
public static async uninstallPlugin (id: string, cleanData?: boolean): Promise<void> {
|
||||
await serverRequest.post<ServerResponse<void>>('/Plugin/Uninstall', { id, cleanData });
|
||||
}
|
||||
|
||||
// 插件商店相关方法
|
||||
public static async getPluginStoreList () {
|
||||
// ==================== 插件商店 ====================
|
||||
|
||||
/**
|
||||
* 获取插件商店列表
|
||||
*/
|
||||
public static async getPluginStoreList (): Promise<PluginStoreList> {
|
||||
const { data } = await serverRequest.get<ServerResponse<PluginStoreList>>('/Plugin/Store/List');
|
||||
return data.data;
|
||||
}
|
||||
|
||||
public static async getPluginStoreDetail (id: string) {
|
||||
/**
|
||||
* 获取插件商店详情
|
||||
* @param id 插件 ID
|
||||
*/
|
||||
public static async getPluginStoreDetail (id: string): Promise<PluginStoreItem> {
|
||||
const { data } = await serverRequest.get<ServerResponse<PluginStoreItem>>(`/Plugin/Store/Detail/${id}`);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
public static async installPluginFromStore (id: string, mirror?: string) {
|
||||
// 插件安装可能需要较长时间(下载+解压),设置5分钟超时
|
||||
await serverRequest.post<ServerResponse<void>>('/Plugin/Store/Install', { id, mirror }, {
|
||||
timeout: 300000, // 5分钟
|
||||
});
|
||||
/**
|
||||
* 从商店安装插件
|
||||
* @param id 插件 ID
|
||||
* @param mirror 镜像源
|
||||
*/
|
||||
public static async installPluginFromStore (id: string, mirror?: string): Promise<void> {
|
||||
await serverRequest.post<ServerResponse<void>>(
|
||||
'/Plugin/Store/Install',
|
||||
{ id, mirror },
|
||||
{ timeout: 300000 } // 5分钟超时
|
||||
);
|
||||
}
|
||||
|
||||
// 插件配置相关方法
|
||||
public static async getPluginConfig (name: string) {
|
||||
const { data } = await serverRequest.get<ServerResponse<PluginConfigResponse>>('/Plugin/Config', { params: { name } });
|
||||
// ==================== 插件配置 ====================
|
||||
|
||||
/**
|
||||
* 获取插件配置
|
||||
* @param id 插件包名
|
||||
*/
|
||||
public static async getPluginConfig (id: string): Promise<PluginConfigResponse> {
|
||||
const { data } = await serverRequest.get<ServerResponse<PluginConfigResponse>>('/Plugin/Config', {
|
||||
params: { id }
|
||||
});
|
||||
return data.data;
|
||||
}
|
||||
|
||||
public static async setPluginConfig (name: string, config: any) {
|
||||
await serverRequest.post<ServerResponse<void>>('/Plugin/Config', { name, config });
|
||||
/**
|
||||
* 设置插件配置
|
||||
* @param id 插件包名
|
||||
* @param config 配置内容
|
||||
*/
|
||||
public static async setPluginConfig (id: string, config: Record<string, unknown>): Promise<void> {
|
||||
await serverRequest.post<ServerResponse<void>>('/Plugin/Config', { id, config });
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,7 +17,7 @@ export default function PluginPage () {
|
||||
const dialog = useDialog();
|
||||
|
||||
const { isOpen, onOpen, onOpenChange } = useDisclosure();
|
||||
const [currentPluginName, setCurrentPluginName] = useState<string>('');
|
||||
const [currentPluginId, setCurrentPluginId] = useState<string>('');
|
||||
|
||||
const loadPlugins = async () => {
|
||||
setLoading(true);
|
||||
@ -49,7 +49,7 @@ export default function PluginPage () {
|
||||
const actionText = isEnable ? '启用' : '禁用';
|
||||
const loadingToast = toast.loading(`${actionText}中...`);
|
||||
try {
|
||||
await PluginManager.setPluginStatus(plugin.name, isEnable, plugin.filename);
|
||||
await PluginManager.setPluginStatus(plugin.id, isEnable);
|
||||
toast.success(`${actionText}成功`, { id: loadingToast });
|
||||
loadPlugins();
|
||||
} catch (e: any) {
|
||||
@ -85,7 +85,7 @@ export default function PluginPage () {
|
||||
|
||||
const loadingToast = toast.loading('卸载中...');
|
||||
try {
|
||||
await PluginManager.uninstallPlugin(plugin.name, plugin.filename, cleanData);
|
||||
await PluginManager.uninstallPlugin(plugin.id, cleanData);
|
||||
toast.success('卸载成功', { id: loadingToast });
|
||||
loadPlugins();
|
||||
resolve();
|
||||
@ -102,7 +102,7 @@ export default function PluginPage () {
|
||||
};
|
||||
|
||||
const handleConfig = (plugin: PluginItem) => {
|
||||
setCurrentPluginName(plugin.name); // Use Loaded Name for config lookup
|
||||
setCurrentPluginId(plugin.id);
|
||||
onOpen();
|
||||
};
|
||||
|
||||
@ -114,7 +114,7 @@ export default function PluginPage () {
|
||||
<PluginConfigModal
|
||||
isOpen={isOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
pluginName={currentPluginName}
|
||||
pluginId={currentPluginId}
|
||||
/>
|
||||
|
||||
<div className='flex mb-6 items-center gap-4'>
|
||||
@ -145,7 +145,7 @@ export default function PluginPage () {
|
||||
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 justify-start items-stretch gap-x-2 gap-y-4'>
|
||||
{plugins.map(plugin => (
|
||||
<PluginDisplayCard
|
||||
key={plugin.name}
|
||||
key={plugin.id}
|
||||
data={plugin}
|
||||
onToggleStatus={() => handleToggle(plugin)}
|
||||
onUninstall={() => handleUninstall(plugin)}
|
||||
|
||||
@ -10,31 +10,32 @@ import PluginManager, { PluginConfigSchemaItem } from '@/controllers/plugin_mana
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onOpenChange: () => void;
|
||||
pluginName: string;
|
||||
/** 插件包名 (id) */
|
||||
pluginId: string;
|
||||
}
|
||||
|
||||
export default function PluginConfigModal ({ isOpen, onOpenChange, pluginName }: Props) {
|
||||
export default function PluginConfigModal ({ isOpen, onOpenChange, pluginId }: Props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [schema, setSchema] = useState<PluginConfigSchemaItem[]>([]);
|
||||
const [config, setConfig] = useState<any>({});
|
||||
const [config, setConfig] = useState<Record<string, unknown>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && pluginName) {
|
||||
if (isOpen && pluginId) {
|
||||
loadConfig();
|
||||
}
|
||||
}, [isOpen, pluginName]);
|
||||
}, [isOpen, pluginId]);
|
||||
|
||||
const loadConfig = async () => {
|
||||
setLoading(true);
|
||||
setSchema([]);
|
||||
setConfig({});
|
||||
try {
|
||||
const data = await PluginManager.getPluginConfig(pluginName);
|
||||
const data = await PluginManager.getPluginConfig(pluginId);
|
||||
setSchema(data.schema || []);
|
||||
setConfig(data.config || {});
|
||||
} catch (e: any) {
|
||||
toast.error('Load config failed: ' + e.message);
|
||||
toast.error('加载配置失败: ' + e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -43,7 +44,7 @@ export default function PluginConfigModal ({ isOpen, onOpenChange, pluginName }:
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await PluginManager.setPluginConfig(pluginName, config);
|
||||
await PluginManager.setPluginConfig(pluginId, config);
|
||||
toast.success('Configuration saved');
|
||||
onOpenChange();
|
||||
} catch (e: any) {
|
||||
@ -177,7 +178,7 @@ export default function PluginConfigModal ({ isOpen, onOpenChange, pluginName }:
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader className="flex flex-col gap-1">
|
||||
Configuration: {pluginName}
|
||||
插件配置: {pluginId}
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
{loading ? (
|
||||
|
||||
@ -10,8 +10,8 @@ import { useRequest } from 'ahooks';
|
||||
import { EventSourcePolyfill } from 'event-source-polyfill';
|
||||
|
||||
import PageLoading from '@/components/page_loading';
|
||||
import PluginStoreCard from '@/components/display_card/plugin_store_card';
|
||||
import PluginManager from '@/controllers/plugin_manager';
|
||||
import PluginStoreCard, { InstallStatus } from '@/components/display_card/plugin_store_card';
|
||||
import PluginManager, { PluginItem } from '@/controllers/plugin_manager';
|
||||
import WebUIManager from '@/controllers/webui_manager';
|
||||
import { PluginStoreItem } from '@/types/plugin-store';
|
||||
import useDialog from '@/hooks/use-dialog';
|
||||
@ -35,6 +35,7 @@ const EmptySection: React.FC<EmptySectionProps> = ({ isEmpty }) => {
|
||||
|
||||
export default function PluginStorePage () {
|
||||
const [plugins, setPlugins] = useState<PluginStoreItem[]>([]);
|
||||
const [installedPlugins, setInstalledPlugins] = useState<PluginItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [activeTab, setActiveTab] = useState<string>('all');
|
||||
@ -57,6 +58,7 @@ export default function PluginStorePage () {
|
||||
// 检查插件管理器是否已加载
|
||||
const listResult = await PluginManager.getPluginList();
|
||||
setPluginManagerNotFound(listResult.pluginManagerNotFound);
|
||||
setInstalledPlugins(listResult.plugins || []);
|
||||
} catch (e: any) {
|
||||
toast.error(e.message);
|
||||
} finally {
|
||||
@ -95,6 +97,23 @@ export default function PluginStorePage () {
|
||||
return categories;
|
||||
}, [plugins, searchQuery]);
|
||||
|
||||
// 获取插件的安装状态和已安装版本
|
||||
const getPluginInstallInfo = (plugin: PluginStoreItem): { status: InstallStatus; installedVersion?: string; } => {
|
||||
// 通过 id (包名) 或 name 匹配已安装的插件
|
||||
const installed = installedPlugins.find(p => p.id === plugin.id);
|
||||
|
||||
if (!installed) {
|
||||
return { status: 'not-installed' };
|
||||
}
|
||||
|
||||
// 使用不等于判断:版本不同就显示更新
|
||||
if (installed.version !== plugin.version) {
|
||||
return { status: 'update-available', installedVersion: installed.version };
|
||||
}
|
||||
|
||||
return { status: 'installed', installedVersion: installed.version };
|
||||
};
|
||||
|
||||
const tabs = useMemo(() => {
|
||||
return [
|
||||
{ key: 'all', title: '全部', count: categorizedPlugins.all?.length || 0 },
|
||||
@ -293,13 +312,18 @@ export default function PluginStorePage () {
|
||||
>
|
||||
<EmptySection isEmpty={!categorizedPlugins[tab.key]?.length} />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 justify-start items-stretch gap-x-2 gap-y-4">
|
||||
{categorizedPlugins[tab.key]?.map((plugin) => (
|
||||
<PluginStoreCard
|
||||
key={plugin.id}
|
||||
data={plugin}
|
||||
onInstall={() => handleInstall(plugin)}
|
||||
/>
|
||||
))}
|
||||
{categorizedPlugins[tab.key]?.map((plugin) => {
|
||||
const installInfo = getPluginInstallInfo(plugin);
|
||||
return (
|
||||
<PluginStoreCard
|
||||
key={plugin.id}
|
||||
data={plugin}
|
||||
installStatus={installInfo.status}
|
||||
installedVersion={installInfo.installedVersion}
|
||||
onInstall={() => handleInstall(plugin)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Tab>
|
||||
))}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user