diff --git a/packages/napcat-onebot/network/plugin-manger.ts b/packages/napcat-onebot/network/plugin-manger.ts index 68afa0b8..42a889e9 100644 --- a/packages/napcat-onebot/network/plugin-manger.ts +++ b/packages/napcat-onebot/network/plugin-manger.ts @@ -15,6 +15,7 @@ import { NapCatPluginContext, IPluginManager, } from './plugin/types'; +import { PluginRouterRegistryImpl } from './plugin/router-registry'; export { PluginPackageJson } from './plugin/types'; export { PluginConfigItem } from './plugin/types'; @@ -25,6 +26,9 @@ export { PluginLogger } from './plugin/types'; export { NapCatPluginContext } from './plugin/types'; export { PluginModule } from './plugin/types'; export { PluginStatusConfig } from './plugin/types'; +export { PluginRouterRegistry, PluginRequestHandler, PluginApiRouteDefinition, PluginPageDefinition, HttpMethod } from './plugin/types'; +export { PluginHttpRequest, PluginHttpResponse, PluginNextFunction } from './plugin/types'; +export { PluginRouterRegistryImpl } from './plugin/router-registry'; export class OB11PluginMangerAdapter extends IOB11NetworkAdapter implements IPluginManager { private readonly pluginPath: string; private readonly configPath: string; @@ -33,6 +37,9 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter i /** 插件注册表: ID -> 插件条目 */ private plugins: Map = new Map(); + /** 插件路由注册表: ID -> 路由注册器 */ + private pluginRouters: Map = new Map(); + declare config: PluginConfig; public NapCatConfig = NapCatConfig; @@ -165,6 +172,13 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter i } } + // 清理插件路由 + const routerRegistry = this.pluginRouters.get(entry.id); + if (routerRegistry) { + routerRegistry.clear(); + this.pluginRouters.delete(entry.id); + } + // 重置状态 entry.loaded = false; entry.runtime = { @@ -192,6 +206,11 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter i error: (...args: any[]) => coreLogger.logError(pluginPrefix, ...args), }; + // 创建插件路由注册器 + const routerRegistry = new PluginRouterRegistryImpl(entry.id, entry.pluginPath); + // 保存到路由注册表 + this.pluginRouters.set(entry.id, routerRegistry); + return { core: this.core, oneBot: this.obContext, @@ -204,6 +223,7 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter i adapterName: this.name, pluginManager: this, logger: pluginLogger, + router: routerRegistry, }; } @@ -237,6 +257,20 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter i return this.plugins.get(pluginId); } + /** + * 获取插件路由注册器 + */ + public getPluginRouter (pluginId: string): PluginRouterRegistryImpl | undefined { + return this.pluginRouters.get(pluginId); + } + + /** + * 获取所有插件路由注册器 + */ + public getAllPluginRouters (): Map { + return this.pluginRouters; + } + /** * 设置插件状态(启用/禁用) */ diff --git a/packages/napcat-onebot/network/plugin/manager.ts b/packages/napcat-onebot/network/plugin/manager.ts index a3efe0bb..9fd3ddd6 100644 --- a/packages/napcat-onebot/network/plugin/manager.ts +++ b/packages/napcat-onebot/network/plugin/manager.ts @@ -8,6 +8,7 @@ import { IOB11NetworkAdapter } from '@/napcat-onebot/network/adapter'; import { PluginConfig } from '@/napcat-onebot/config/config'; import { NapCatConfig } from './config'; import { PluginLoader } from './loader'; +import { PluginRouterRegistryImpl } from './router-registry'; import { PluginEntry, PluginLogger, @@ -24,6 +25,9 @@ export class OB11PluginManager extends IOB11NetworkAdapter impleme /** 插件注册表: ID -> 插件条目 */ private plugins: Map = new Map(); + /** 插件路由注册表: pluginId -> PluginRouterRegistry */ + private pluginRouters: Map = new Map(); + declare config: PluginConfig; public NapCatConfig = NapCatConfig; @@ -183,6 +187,13 @@ export class OB11PluginManager extends IOB11NetworkAdapter impleme error: (...args: any[]) => coreLogger.logError(pluginPrefix, ...args), }; + // 创建或获取插件路由注册器 + let router = this.pluginRouters.get(entry.id); + if (!router) { + router = new PluginRouterRegistryImpl(entry.id, entry.pluginPath); + this.pluginRouters.set(entry.id, router); + } + return { core: this.core, oneBot: this.obContext, @@ -195,6 +206,7 @@ export class OB11PluginManager extends IOB11NetworkAdapter impleme adapterName: this.name, pluginManager: this, logger: pluginLogger, + router, }; } @@ -391,6 +403,20 @@ export class OB11PluginManager extends IOB11NetworkAdapter impleme return path.join(this.getPluginDataPath(pluginId), 'config.json'); } + /** + * 获取插件路由注册器 + */ + public getPluginRouter (pluginId: string): PluginRouterRegistryImpl | undefined { + return this.pluginRouters.get(pluginId); + } + + /** + * 获取所有插件路由注册器 + */ + public getAllPluginRouters (): Map { + return this.pluginRouters; + } + // ==================== 事件处理 ==================== async onEvent (event: T): Promise { diff --git a/packages/napcat-onebot/network/plugin/router-registry.ts b/packages/napcat-onebot/network/plugin/router-registry.ts new file mode 100644 index 00000000..a0b250f9 --- /dev/null +++ b/packages/napcat-onebot/network/plugin/router-registry.ts @@ -0,0 +1,221 @@ +import { Router, static as expressStatic, Request, Response, NextFunction } from 'express'; +import path from 'path'; +import { + PluginRouterRegistry, + PluginRequestHandler, + PluginApiRouteDefinition, + PluginPageDefinition, + PluginHttpRequest, + PluginHttpResponse, + HttpMethod, +} from './types'; + +/** + * 包装 Express Request 为 PluginHttpRequest + */ +function wrapRequest (req: Request): PluginHttpRequest { + return { + path: req.path, + method: req.method, + query: req.query as Record, + body: req.body, + headers: req.headers as Record, + params: req.params, + raw: req, + }; +} + +/** + * 包装 Express Response 为 PluginHttpResponse + */ +function wrapResponse (res: Response): PluginHttpResponse { + const wrapped: PluginHttpResponse = { + status (code: number) { + res.status(code); + return wrapped; + }, + json (data: unknown) { + res.json(data); + }, + send (data: string | Buffer) { + res.send(data); + }, + setHeader (name: string, value: string) { + res.setHeader(name, value); + return wrapped; + }, + sendFile (filePath: string) { + res.sendFile(filePath); + }, + redirect (url: string) { + res.redirect(url); + }, + raw: res, + }; + return wrapped; +} + +/** + * 插件路由注册器实现 + * 为每个插件创建独立的路由注册器,收集路由定义 + */ +export class PluginRouterRegistryImpl implements PluginRouterRegistry { + private apiRoutes: PluginApiRouteDefinition[] = []; + private pageDefinitions: PluginPageDefinition[] = []; + private staticRoutes: Array<{ urlPath: string; localPath: string; }> = []; + + constructor ( + private readonly pluginId: string, + private readonly pluginPath: string + ) { } + + // ==================== API 路由注册 ==================== + + api (method: HttpMethod, routePath: string, handler: PluginRequestHandler): void { + this.apiRoutes.push({ method, path: routePath, handler }); + } + + get (routePath: string, handler: PluginRequestHandler): void { + this.api('get', routePath, handler); + } + + post (routePath: string, handler: PluginRequestHandler): void { + this.api('post', routePath, handler); + } + + put (routePath: string, handler: PluginRequestHandler): void { + this.api('put', routePath, handler); + } + + delete (routePath: string, handler: PluginRequestHandler): void { + this.api('delete', routePath, handler); + } + + // ==================== 页面注册 ==================== + + page (pageDef: PluginPageDefinition): void { + this.pageDefinitions.push(pageDef); + } + + pages (pageDefs: PluginPageDefinition[]): void { + this.pageDefinitions.push(...pageDefs); + } + + // ==================== 静态资源 ==================== + + static (urlPath: string, localPath: string): void { + // 如果是相对路径,则相对于插件目录 + const absolutePath = path.isAbsolute(localPath) + ? localPath + : path.join(this.pluginPath, localPath); + this.staticRoutes.push({ urlPath, localPath: absolutePath }); + } + + // ==================== 构建路由 ==================== + + /** + * 构建 Express Router(用于 API 路由) + */ + buildApiRouter (): Router { + const router = Router(); + + // 注册静态文件路由 + for (const { urlPath, localPath } of this.staticRoutes) { + router.use(urlPath, expressStatic(localPath)); + } + + // 注册 API 路由 + for (const route of this.apiRoutes) { + const handler = this.wrapHandler(route.handler); + switch (route.method) { + case 'get': + router.get(route.path, handler); + break; + case 'post': + router.post(route.path, handler); + break; + case 'put': + router.put(route.path, handler); + break; + case 'delete': + router.delete(route.path, handler); + break; + case 'patch': + router.patch(route.path, handler); + break; + case 'all': + router.all(route.path, handler); + break; + } + } + + return router; + } + + /** + * 包装处理器,添加错误处理和请求/响应包装 + */ + private wrapHandler (handler: PluginRequestHandler): (req: Request, res: Response, next: NextFunction) => void { + return async (req: Request, res: Response, next: NextFunction) => { + try { + const wrappedReq = wrapRequest(req); + const wrappedRes = wrapResponse(res); + await handler(wrappedReq, wrappedRes, next); + } catch (error: any) { + console.error(`[Plugin: ${this.pluginId}] Route error:`, error); + if (!res.headersSent) { + res.status(500).json({ + code: -1, + message: `Plugin error: ${error.message || 'Unknown error'}`, + }); + } + } + }; + } + + // ==================== 查询方法 ==================== + + /** + * 检查是否有注册的 API 路由 + */ + hasApiRoutes (): boolean { + return this.apiRoutes.length > 0 || this.staticRoutes.length > 0; + } + + /** + * 检查是否有注册的页面 + */ + hasPages (): boolean { + return this.pageDefinitions.length > 0; + } + + /** + * 获取所有注册的页面定义 + */ + getPages (): PluginPageDefinition[] { + return [...this.pageDefinitions]; + } + + /** + * 获取插件 ID + */ + getPluginId (): string { + return this.pluginId; + } + + /** + * 获取插件路径 + */ + getPluginPath (): string { + return this.pluginPath; + } + + /** + * 清空路由(用于插件卸载) + */ + clear (): void { + this.apiRoutes = []; + this.pageDefinitions = []; + this.staticRoutes = []; + } +} diff --git a/packages/napcat-onebot/network/plugin/types.ts b/packages/napcat-onebot/network/plugin/types.ts index abc333db..448b276b 100644 --- a/packages/napcat-onebot/network/plugin/types.ts +++ b/packages/napcat-onebot/network/plugin/types.ts @@ -2,6 +2,7 @@ import { NapCatCore } from 'napcat-core'; import { NapCatOneBot11Adapter, OB11Message } from '@/napcat-onebot/index'; import { ActionMap } from '@/napcat-onebot/action'; import { OB11EmitEventContent } from '@/napcat-onebot/network/index'; +import { NetworkAdapterConfig } from '@/napcat-onebot/config/config'; // ==================== 插件包信息 ==================== @@ -49,11 +50,130 @@ export interface INapCatConfigStatic { /** NapCatConfig 类型(包含静态方法) */ export type NapCatConfigClass = INapCatConfigStatic; +// ==================== 插件路由相关类型(包装层,不直接依赖 express) ==================== + +/** HTTP 请求对象(包装类型) */ +export interface PluginHttpRequest { + /** 请求路径 */ + path: string; + /** 请求方法 */ + method: string; + /** 查询参数 */ + query: Record; + /** 请求体 */ + body: unknown; + /** 请求头 */ + headers: Record; + /** 路由参数 */ + params: Record; + /** 原始请求对象(用于高级用法) */ + raw: unknown; +} + +/** HTTP 响应对象(包装类型) */ +export interface PluginHttpResponse { + /** 设置状态码 */ + status (code: number): PluginHttpResponse; + /** 发送 JSON 响应 */ + json (data: unknown): void; + /** 发送文本响应 */ + send (data: string | Buffer): void; + /** 设置响应头 */ + setHeader (name: string, value: string): PluginHttpResponse; + /** 发送文件 */ + sendFile (filePath: string): void; + /** 重定向 */ + redirect (url: string): void; + /** 原始响应对象(用于高级用法) */ + raw: unknown; +} + +/** 下一步函数类型 */ +export type PluginNextFunction = (err?: unknown) => void; + +/** 插件请求处理器类型 */ +export type PluginRequestHandler = ( + req: PluginHttpRequest, + res: PluginHttpResponse, + next: PluginNextFunction +) => void | Promise; + +/** HTTP 方法类型 */ +export type HttpMethod = 'get' | 'post' | 'put' | 'delete' | 'patch' | 'all'; + +/** 插件 API 路由定义 */ +export interface PluginApiRouteDefinition { + /** HTTP 方法 */ + method: HttpMethod; + /** 路由路径(相对于插件路由前缀) */ + path: string; + /** 请求处理器 */ + handler: PluginRequestHandler; +} + +/** 插件页面定义 */ +export interface PluginPageDefinition { + /** 页面路径(用于路由,如 'settings') */ + path: string; + /** 页面标题(显示在 Tab 上) */ + title: string; + /** 页面图标(可选,支持 emoji 或图标名) */ + icon?: string; + /** 页面 HTML 文件路径(相对于插件目录) */ + htmlFile: string; + /** 页面描述 */ + description?: string; +} + +/** 插件路由注册器 */ +export interface PluginRouterRegistry { + // ==================== API 路由注册 ==================== + + /** + * 注册单个 API 路由 + * @param method HTTP 方法 + * @param path 路由路径 + * @param handler 请求处理器 + */ + api (method: HttpMethod, path: string, handler: PluginRequestHandler): void; + /** 注册 GET API */ + get (path: string, handler: PluginRequestHandler): void; + /** 注册 POST API */ + post (path: string, handler: PluginRequestHandler): void; + /** 注册 PUT API */ + put (path: string, handler: PluginRequestHandler): void; + /** 注册 DELETE API */ + delete (path: string, handler: PluginRequestHandler): void; + + // ==================== 页面注册 ==================== + + /** + * 注册插件页面 + * @param page 页面定义 + */ + page (page: PluginPageDefinition): void; + + /** + * 注册多个插件页面 + * @param pages 页面定义数组 + */ + pages (pages: PluginPageDefinition[]): void; + + // ==================== 静态资源 ==================== + + /** + * 提供静态文件服务 + * @param urlPath URL 路径 + * @param localPath 本地文件夹路径(相对于插件目录或绝对路径) + */ + static (urlPath: string, localPath: string): void; +} + // ==================== 插件管理器接口 ==================== /** 插件管理器公共接口 */ export interface IPluginManager { - readonly config: unknown; + readonly config: NetworkAdapterConfig; getPluginPath (): string; getPluginConfig (): PluginStatusConfig; getAllPlugins (): PluginEntry[]; @@ -124,11 +244,16 @@ export interface NapCatPluginContext { pluginManager: IPluginManager; /** 插件日志器 - 自动添加插件名称前缀 */ logger: PluginLogger; + /** + * WebUI 路由注册器 + * 用于注册插件的 HTTP API 路由,路由将挂载到 /api/Plugin/ext/{pluginId}/ + */ + router: PluginRouterRegistry; } // ==================== 插件模块接口 ==================== -export interface PluginModule { +export interface PluginModule { plugin_init: (ctx: NapCatPluginContext) => void | Promise; plugin_onmessage?: ( ctx: NapCatPluginContext, @@ -143,8 +268,8 @@ export interface PluginModule void | Promise; plugin_config_schema?: PluginConfigSchema; plugin_config_ui?: PluginConfigSchema; - plugin_get_config?: (ctx: NapCatPluginContext) => unknown | Promise; - plugin_set_config?: (ctx: NapCatPluginContext, config: unknown) => void | Promise; + plugin_get_config?: (ctx: NapCatPluginContext) => C | Promise; + plugin_set_config?: (ctx: NapCatPluginContext, config: C) => void | Promise; /** * 配置界面控制器 - 当配置界面打开时调用 * 返回清理函数,在界面关闭时调用 diff --git a/packages/napcat-plugin-builtin/index.ts b/packages/napcat-plugin-builtin/index.ts index b6122934..290f9b62 100644 --- a/packages/napcat-plugin-builtin/index.ts +++ b/packages/napcat-plugin-builtin/index.ts @@ -63,14 +63,68 @@ const plugin_init: PluginModule['plugin_init'] = async (ctx) => { logger?.warn('Failed to load config', e); } + // ==================== 注册 WebUI 路由示例 ==================== + + // 注册静态资源目录(webui 目录下的文件可通过 /api/Plugin/ext/{pluginId}/static/ 访问) + ctx.router.static('/static', 'webui'); + + // 注册 API 路由 + ctx.router.get('/status', (_req, res) => { + const uptime = Date.now() - startTime; + res.json({ + code: 0, + data: { + pluginName: ctx.pluginName, + uptime, + uptimeFormatted: formatUptime(uptime), + config: currentConfig, + platform: process.platform, + arch: process.arch + } + }); + }); + + ctx.router.get('/config', (_req, res) => { + res.json({ + code: 0, + data: currentConfig + }); + }); + + ctx.router.post('/config', (req, res) => { + try { + const newConfig = req.body as Partial; + Object.assign(currentConfig, newConfig); + // 保存配置 + const configDir = path.dirname(ctx.configPath); + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true }); + } + fs.writeFileSync(ctx.configPath, JSON.stringify(currentConfig, null, 2), 'utf-8'); + res.json({ code: 0, message: 'Config saved successfully' }); + } catch (e: any) { + res.status(500).json({ code: -1, message: e.message }); + } + }); + + // 注册扩展页面 + ctx.router.page({ + path: 'dashboard', + title: '插件仪表盘', + icon: '📊', + htmlFile: 'webui/dashboard.html', + description: '查看内置插件的运行状态和配置' + }); + + logger.info('WebUI 路由已注册: /api/Plugin/ext/' + ctx.pluginName); }; export const plugin_get_config: PluginModule['plugin_get_config'] = async () => { return currentConfig; }; -export const plugin_set_config: PluginModule['plugin_set_config'] = async (ctx, config: BuiltinPluginConfig) => { - currentConfig = config; +export const plugin_set_config: PluginModule['plugin_set_config'] = async (ctx, config) => { + currentConfig = config as BuiltinPluginConfig; if (ctx && ctx.configPath) { try { const configPath = ctx.configPath; diff --git a/packages/napcat-plugin-builtin/package.json b/packages/napcat-plugin-builtin/package.json index 902148c6..b11601e0 100644 --- a/packages/napcat-plugin-builtin/package.json +++ b/packages/napcat-plugin-builtin/package.json @@ -7,7 +7,7 @@ "description": "NapCat 内置插件", "author": "NapNeko", "dependencies": { - "napcat-types": "0.0.11" + "napcat-types": "0.0.14" }, "devDependencies": { "@types/node": "^22.0.1" diff --git a/packages/napcat-plugin-builtin/vite.config.ts b/packages/napcat-plugin-builtin/vite.config.ts index 41e90e9f..1a629eec 100644 --- a/packages/napcat-plugin-builtin/vite.config.ts +++ b/packages/napcat-plugin-builtin/vite.config.ts @@ -14,8 +14,10 @@ function copyToShellPlugin () { writeBundle () { try { const sourceDir = resolve(__dirname, 'dist'); - const targetDir = resolve(__dirname, '../napcat-shell/dist/plugins/builtin'); + const targetDir = resolve(__dirname, '../napcat-shell/dist/plugins/napcat-plugin-builtin'); const packageJsonSource = resolve(__dirname, 'package.json'); + const webuiSourceDir = resolve(__dirname, 'webui'); + const webuiTargetDir = resolve(targetDir, 'webui'); // 确保目标目录存在 if (!fs.existsSync(targetDir)) { @@ -44,6 +46,12 @@ function copyToShellPlugin () { copiedCount++; } + // 拷贝 webui 目录 + if (fs.existsSync(webuiSourceDir)) { + copyDirRecursive(webuiSourceDir, webuiTargetDir); + console.log(`[copy-to-shell] Copied webui directory to ${webuiTargetDir}`); + } + console.log(`[copy-to-shell] Successfully copied ${copiedCount} file(s) to ${targetDir}`); } catch (error) { console.error('[copy-to-shell] Failed to copy files:', error); @@ -53,6 +61,26 @@ function copyToShellPlugin () { }; } +// 递归复制目录 +function copyDirRecursive (src: string, dest: string) { + if (!fs.existsSync(dest)) { + fs.mkdirSync(dest, { recursive: true }); + } + + const entries = fs.readdirSync(src, { withFileTypes: true }); + + for (const entry of entries) { + const srcPath = resolve(src, entry.name); + const destPath = resolve(dest, entry.name); + + if (entry.isDirectory()) { + copyDirRecursive(srcPath, destPath); + } else { + fs.copyFileSync(srcPath, destPath); + } + } +} + export default defineConfig({ resolve: { conditions: ['node', 'default'], diff --git a/packages/napcat-plugin-builtin/webui/dashboard.html b/packages/napcat-plugin-builtin/webui/dashboard.html new file mode 100644 index 00000000..32268b40 --- /dev/null +++ b/packages/napcat-plugin-builtin/webui/dashboard.html @@ -0,0 +1,414 @@ + + + + + + + 内置插件仪表盘 + + + + +
+
+
+

NapCat 内置插件仪表盘

+
+ +
+
加载中
+
+
+ +
+
+

当前配置

+
+
+
加载中
+
+
+ +
+
+

静态资源测试

+
+
+

+ 测试插件静态资源服务是否正常工作 +

+
+ +
+
+
+
+ + +
+ + + + + \ No newline at end of file diff --git a/packages/napcat-plugin-builtin/webui/test.txt b/packages/napcat-plugin-builtin/webui/test.txt new file mode 100644 index 00000000..2d512801 --- /dev/null +++ b/packages/napcat-plugin-builtin/webui/test.txt @@ -0,0 +1,6 @@ +Hello from NapCat Builtin Plugin! + +这是一个静态资源测试文件。 +如果你能看到这段文字,说明插件的静态资源服务正常工作。 + +时间戳: 2026-01-30 diff --git a/packages/napcat-types/package.public.json b/packages/napcat-types/package.public.json index 6cb3effb..73700bde 100644 --- a/packages/napcat-types/package.public.json +++ b/packages/napcat-types/package.public.json @@ -1,6 +1,6 @@ { "name": "napcat-types", - "version": "0.0.11", + "version": "0.0.14", "private": false, "type": "module", "types": "./napcat-types/index.d.ts", diff --git a/packages/napcat-webui-backend/src/api/Plugin.ts b/packages/napcat-webui-backend/src/api/Plugin.ts index 484c0777..0367ba2b 100644 --- a/packages/napcat-webui-backend/src/api/Plugin.ts +++ b/packages/napcat-webui-backend/src/api/Plugin.ts @@ -62,7 +62,7 @@ export const GetPluginListHandler: RequestHandler = async (_req, res) => { const pluginManager = getPluginManager(); if (!pluginManager) { // 返回成功但带特殊标记 - return sendSuccess(res, { plugins: [], pluginManagerNotFound: true }); + return sendSuccess(res, { plugins: [], pluginManagerNotFound: true, extensionPages: [] }); } const loadedPlugins = pluginManager.getAllPlugins(); @@ -74,8 +74,19 @@ export const GetPluginListHandler: RequestHandler = async (_req, res) => { author: string; status: string; hasConfig: boolean; + hasPages: boolean; }> = new Array(); + // 收集所有插件的扩展页面 + const extensionPages: Array<{ + pluginId: string; + pluginName: string; + path: string; + title: string; + icon?: string; + description?: string; + }> = []; + // 1. 整理已加载的插件 for (const p of loadedPlugins) { // 根据插件状态确定 status @@ -88,6 +99,10 @@ export const GetPluginListHandler: RequestHandler = async (_req, res) => { status = 'stopped'; // 启用但未加载(可能加载失败) } + // 检查插件是否有注册页面 + const pluginRouter = pluginManager.getPluginRouter(p.id); + const hasPages = pluginRouter?.hasPages() ?? false; + AllPlugins.push({ name: p.packageJson?.plugin || p.name || '', // 优先显示 package.json 的 plugin 字段 id: p.id, // 包名,用于 API 操作 @@ -95,12 +110,28 @@ export const GetPluginListHandler: RequestHandler = async (_req, res) => { description: p.packageJson?.description || '', author: p.packageJson?.author || '', status, - hasConfig: !!(p.runtime.module?.plugin_config_schema || p.runtime.module?.plugin_config_ui) + hasConfig: !!(p.runtime.module?.plugin_config_schema || p.runtime.module?.plugin_config_ui), + hasPages }); + + // 收集插件的扩展页面 + if (hasPages && pluginRouter) { + const pages = pluginRouter.getPages(); + for (const page of pages) { + extensionPages.push({ + pluginId: p.id, + pluginName: p.packageJson?.plugin || p.name || p.id, + path: page.path, + title: page.title, + icon: page.icon, + description: page.description + }); + } + } } - return sendSuccess(res, { plugins: AllPlugins, pluginManagerNotFound: false }); + return sendSuccess(res, { plugins: AllPlugins, pluginManagerNotFound: false, extensionPages }); }; export const SetPluginStatusHandler: RequestHandler = async (req, res) => { diff --git a/packages/napcat-webui-backend/src/middleware/auth.ts b/packages/napcat-webui-backend/src/middleware/auth.ts index a352076b..f1e9bb39 100644 --- a/packages/napcat-webui-backend/src/middleware/auth.ts +++ b/packages/napcat-webui-backend/src/middleware/auth.ts @@ -16,10 +16,7 @@ export async function auth (req: Request, res: Response, next: NextFunction) { req.url === '/auth/passkey/verify-authentication') { return next(); } - - - - // 判断是否有Authorization头 + let hash: string | undefined; if (req.headers?.authorization) { // 切割参数以获取token const authorization = req.headers.authorization.split(' '); @@ -28,8 +25,14 @@ export async function auth (req: Request, res: Response, next: NextFunction) { return sendError(res, 'Unauthorized'); } // 获取token - const hash = authorization[1]; - if (!hash) return sendError(res, 'Unauthorized'); + hash = authorization[1]; + } else if (req.query['webui_token'] && typeof req.query['webui_token'] === 'string') { + // 支持通过query参数传递token + hash = req.query['webui_token']; + } + // 判断是否有Authorization头 + if (hash) { + //if (!hash) return sendError(res, 'Unauthorized'); // 解析token let Credential: WebUiCredentialJson; try { diff --git a/packages/napcat-webui-backend/src/router/Plugin.ts b/packages/napcat-webui-backend/src/router/Plugin.ts index cf9824cd..bb27847f 100644 --- a/packages/napcat-webui-backend/src/router/Plugin.ts +++ b/packages/napcat-webui-backend/src/router/Plugin.ts @@ -20,6 +20,9 @@ import { InstallPluginFromStoreHandler, InstallPluginFromStoreSSEHandler } from '@/napcat-webui-backend/src/api/PluginStore'; +import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data'; +import { NapCatOneBot11Adapter } from '@/napcat-onebot/index'; +import { OB11PluginMangerAdapter } from '@/napcat-onebot/network/plugin-manger'; // 配置 multer 用于文件上传 const uploadDir = path.join(os.tmpdir(), 'napcat-plugin-uploads'); @@ -72,4 +75,92 @@ router.get('/Store/Detail/:id', GetPluginStoreDetailHandler); router.post('/Store/Install', InstallPluginFromStoreHandler); router.get('/Store/Install/SSE', InstallPluginFromStoreSSEHandler); +// 插件扩展路由 - 动态挂载插件注册的 API 路由 +router.use('/ext/:pluginId', (req, res, next): void => { + const { pluginId } = req.params; + + if (!pluginId) { + res.status(400).json({ code: -1, message: 'Plugin ID is required' }); + return; + } + + // 获取插件管理器 + const ob11 = WebUiDataRuntime.getOneBotContext() as NapCatOneBot11Adapter; + if (!ob11) { + res.status(503).json({ code: -1, message: 'OneBot context not available' }); + return; + } + + const pluginManager = ob11.networkManager.findSomeAdapter('plugin_manager') as OB11PluginMangerAdapter; + if (!pluginManager) { + res.status(503).json({ code: -1, message: 'Plugin manager not available' }); + return; + } + + // 获取插件路由 + const routerRegistry = pluginManager.getPluginRouter(pluginId); + if (!routerRegistry || !routerRegistry.hasApiRoutes()) { + res.status(404).json({ code: -1, message: `Plugin '${pluginId}' has no registered API routes` }); + return; + } + + // 构建并执行插件路由 + const pluginRouter = routerRegistry.buildApiRouter(); + pluginRouter(req, res, next); +}); + +// 插件页面路由 - 服务插件注册的 HTML 页面 +router.get('/page/:pluginId/:pagePath', (req, res): void => { + const { pluginId, pagePath } = req.params; + + if (!pluginId) { + res.status(400).json({ code: -1, message: 'Plugin ID is required' }); + return; + } + + // 获取插件管理器 + const ob11 = WebUiDataRuntime.getOneBotContext() as NapCatOneBot11Adapter; + if (!ob11) { + res.status(503).json({ code: -1, message: 'OneBot context not available' }); + return; + } + + const pluginManager = ob11.networkManager.findSomeAdapter('plugin_manager') as OB11PluginMangerAdapter; + if (!pluginManager) { + res.status(503).json({ code: -1, message: 'Plugin manager not available' }); + return; + } + + // 获取插件路由 + const routerRegistry = pluginManager.getPluginRouter(pluginId); + if (!routerRegistry || !routerRegistry.hasPages()) { + res.status(404).json({ code: -1, message: `Plugin '${pluginId}' has no registered pages` }); + return; + } + + // 查找匹配的页面 + const pages = routerRegistry.getPages(); + const page = pages.find(p => p.path === '/' + pagePath || p.path === pagePath); + if (!page) { + res.status(404).json({ code: -1, message: `Page '${pagePath}' not found in plugin '${pluginId}'` }); + return; + } + + // 获取插件路径 + const pluginPath = routerRegistry.getPluginPath(); + if (!pluginPath) { + res.status(500).json({ code: -1, message: 'Plugin path not available' }); + return; + } + + // 构建 HTML 文件路径并发送 + const htmlFilePath = path.join(pluginPath, page.htmlFile); + if (!fs.existsSync(htmlFilePath)) { + res.status(404).json({ code: -1, message: `HTML file not found: ${page.htmlFile}` }); + return; + } + + res.sendFile(htmlFilePath); +}); + export { router as PluginRouter }; diff --git a/packages/napcat-webui-frontend/src/App.tsx b/packages/napcat-webui-frontend/src/App.tsx index 3c502e44..b1a7b11a 100644 --- a/packages/napcat-webui-frontend/src/App.tsx +++ b/packages/napcat-webui-frontend/src/App.tsx @@ -27,6 +27,7 @@ const NetworkPage = lazy(() => import('@/pages/dashboard/network')); const TerminalPage = lazy(() => import('@/pages/dashboard/terminal')); const PluginPage = lazy(() => import('@/pages/dashboard/plugin')); const PluginStorePage = lazy(() => import('@/pages/dashboard/plugin_store')); +const ExtensionPage = lazy(() => import('@/pages/dashboard/extension')); function App () { return ( @@ -80,6 +81,7 @@ function AppRoutes () { } /> } /> } /> + } /> } /> } /> diff --git a/packages/napcat-webui-frontend/src/config/site.tsx b/packages/napcat-webui-frontend/src/config/site.tsx index 54aabae0..eccc8ec6 100644 --- a/packages/napcat-webui-frontend/src/config/site.tsx +++ b/packages/napcat-webui-frontend/src/config/site.tsx @@ -10,6 +10,7 @@ import { LuZap, LuPackage, LuStore, + LuPuzzle, } from 'react-icons/lu'; export type SiteConfig = typeof siteConfig; @@ -66,6 +67,11 @@ export const siteConfig = { icon: , href: '/plugin_store', }, + { + label: '扩展页面', + icon: , + href: '/extension', + }, { label: '系统终端', icon: , diff --git a/packages/napcat-webui-frontend/src/controllers/plugin_manager.ts b/packages/napcat-webui-frontend/src/controllers/plugin_manager.ts index f5249e10..1e36a917 100644 --- a/packages/napcat-webui-frontend/src/controllers/plugin_manager.ts +++ b/packages/napcat-webui-frontend/src/controllers/plugin_manager.ts @@ -20,12 +20,31 @@ export interface PluginItem { status: PluginStatus; /** 是否有配置项 */ hasConfig?: boolean; + /** 是否有扩展页面 */ + hasPages?: boolean; +} + +/** 扩展页面信息 */ +export interface ExtensionPageItem { + /** 插件 ID */ + pluginId: string; + /** 插件名称 */ + pluginName: string; + /** 页面路径 */ + path: string; + /** 页面标题 */ + title: string; + /** 页面图标 */ + icon?: string; + /** 页面描述 */ + description?: string; } /** 插件列表响应 */ export interface PluginListResponse { plugins: PluginItem[]; pluginManagerNotFound: boolean; + extensionPages: ExtensionPageItem[]; } /** 插件配置项定义 */ diff --git a/packages/napcat-webui-frontend/src/pages/dashboard/extension.tsx b/packages/napcat-webui-frontend/src/pages/dashboard/extension.tsx new file mode 100644 index 00000000..9b3dbe99 --- /dev/null +++ b/packages/napcat-webui-frontend/src/pages/dashboard/extension.tsx @@ -0,0 +1,162 @@ +import { Tab, Tabs } from '@heroui/tabs'; +import { Button } from '@heroui/button'; +import { Spinner } from '@heroui/spinner'; +import { useEffect, useState, useMemo } from 'react'; +import toast from 'react-hot-toast'; +import { IoMdRefresh } from 'react-icons/io'; +import { MdExtension } from 'react-icons/md'; + +import PageLoading from '@/components/page_loading'; +import pluginManager from '@/controllers/plugin_manager'; + +interface ExtensionPage { + pluginId: string; + pluginName: string; + path: string; + title: string; + icon?: string; + description?: string; +} + +export default function ExtensionPage () { + const [loading, setLoading] = useState(true); + const [extensionPages, setExtensionPages] = useState([]); + const [selectedTab, setSelectedTab] = useState(''); + const [iframeLoading, setIframeLoading] = useState(false); + + const fetchExtensionPages = async () => { + setLoading(true); + try { + const result = await pluginManager.getPluginList(); + if (result.pluginManagerNotFound) { + setExtensionPages([]); + } else { + setExtensionPages(result.extensionPages || []); + // 默认选中第一个 + if (result.extensionPages?.length > 0 && !selectedTab) { + setSelectedTab(`${result.extensionPages[0].pluginId}:${result.extensionPages[0].path}`); + } + } + } catch (error) { + const msg = (error as Error).message; + toast.error(`获取扩展页面失败: ${msg}`); + } finally { + setLoading(false); + } + }; + + const refresh = async () => { + await fetchExtensionPages(); + }; + + // 生成 tabs + const tabs = useMemo(() => { + return extensionPages.map(page => ({ + key: `${page.pluginId}:${page.path}`, + title: page.title, + pluginId: page.pluginId, + pluginName: page.pluginName, + path: page.path, + icon: page.icon, + description: page.description + })); + }, [extensionPages]); + + // 获取当前选中页面的 iframe URL + const currentPageUrl = useMemo(() => { + if (!selectedTab) return ''; + const [pluginId, ...pathParts] = selectedTab.split(':'); + const path = pathParts.join(':').replace(/^\//, ''); + // 获取认证 token + const token = localStorage.getItem('token') || ''; + return `/api/Plugin/page/${pluginId}/${path}?webui_token=${encodeURIComponent(token)}`; + }, [selectedTab]); + + useEffect(() => { + fetchExtensionPages(); + }, []); + + useEffect(() => { + if (currentPageUrl) { + setIframeLoading(true); + } + }, [currentPageUrl]); + + const handleIframeLoad = () => { + setIframeLoading(false); + }; + + return ( + <> + 扩展页面 - NapCat WebUI +
+ + +
+
+ + 插件扩展页面 +
+ +
+ + {extensionPages.length === 0 && !loading ? ( +
+ +

暂无插件扩展页面

+

插件可以通过注册页面来扩展 WebUI 功能

+
+ ) : ( +
+ setSelectedTab(key as string)} + classNames={{ + tabList: 'bg-white/40 dark:bg-black/20 backdrop-blur-md flex-wrap', + cursor: 'bg-white/80 dark:bg-white/10 backdrop-blur-md shadow-sm', + panel: 'flex-1 min-h-0 p-0' + }} + > + {tabs.map((tab) => ( + + {tab.icon && {tab.icon}} + {tab.title} + ({tab.pluginName}) +
+ } + > +
+ {iframeLoading && ( +
+ +
+ )} +