From c38b98a0c4a4a43690b24f1611b3c129107d5c13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=89=8B=E7=93=9C=E4=B8=80=E5=8D=81=E9=9B=AA?= Date: Fri, 30 Jan 2026 12:48:24 +0800 Subject: [PATCH] Add plugin WebUI extension page and API routing support Introduces a plugin router registry for registering plugin-specific API routes, static resources, and extension pages. Updates the plugin manager and context to expose the router, and implements backend and frontend support for serving and displaying plugin extension pages in the WebUI. Also adds a demo extension page and static resource to the builtin plugin. --- .../napcat-onebot/network/plugin-manger.ts | 34 ++ .../napcat-onebot/network/plugin/manager.ts | 26 ++ .../network/plugin/router-registry.ts | 221 ++++++++++ .../napcat-onebot/network/plugin/types.ts | 133 +++++- packages/napcat-plugin-builtin/index.ts | 58 ++- packages/napcat-plugin-builtin/package.json | 2 +- packages/napcat-plugin-builtin/vite.config.ts | 30 +- .../webui/dashboard.html | 414 ++++++++++++++++++ packages/napcat-plugin-builtin/webui/test.txt | 6 + packages/napcat-types/package.public.json | 2 +- .../napcat-webui-backend/src/api/Plugin.ts | 37 +- .../src/middleware/auth.ts | 15 +- .../napcat-webui-backend/src/router/Plugin.ts | 91 ++++ packages/napcat-webui-frontend/src/App.tsx | 2 + .../napcat-webui-frontend/src/config/site.tsx | 6 + .../src/controllers/plugin_manager.ts | 19 + .../src/pages/dashboard/extension.tsx | 162 +++++++ pnpm-lock.yaml | 10 +- 18 files changed, 1245 insertions(+), 23 deletions(-) create mode 100644 packages/napcat-onebot/network/plugin/router-registry.ts create mode 100644 packages/napcat-plugin-builtin/webui/dashboard.html create mode 100644 packages/napcat-plugin-builtin/webui/test.txt create mode 100644 packages/napcat-webui-frontend/src/pages/dashboard/extension.tsx 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 && ( +
+ +
+ )} +