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.
This commit is contained in:
手瓜一十雪 2026-01-30 12:48:24 +08:00
parent 05d27e86ce
commit c38b98a0c4
18 changed files with 1245 additions and 23 deletions

View File

@ -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<PluginConfig> implements IPluginManager {
private readonly pluginPath: string;
private readonly configPath: string;
@ -33,6 +37,9 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> i
/** 插件注册表: ID -> 插件条目 */
private plugins: Map<string, PluginEntry> = new Map();
/** 插件路由注册表: ID -> 路由注册器 */
private pluginRouters: Map<string, PluginRouterRegistryImpl> = new Map();
declare config: PluginConfig;
public NapCatConfig = NapCatConfig;
@ -165,6 +172,13 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> 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<PluginConfig> 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<PluginConfig> i
adapterName: this.name,
pluginManager: this,
logger: pluginLogger,
router: routerRegistry,
};
}
@ -237,6 +257,20 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> i
return this.plugins.get(pluginId);
}
/**
*
*/
public getPluginRouter (pluginId: string): PluginRouterRegistryImpl | undefined {
return this.pluginRouters.get(pluginId);
}
/**
*
*/
public getAllPluginRouters (): Map<string, PluginRouterRegistryImpl> {
return this.pluginRouters;
}
/**
* /
*/

View File

@ -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<PluginConfig> impleme
/** 插件注册表: ID -> 插件条目 */
private plugins: Map<string, PluginEntry> = new Map();
/** 插件路由注册表: pluginId -> PluginRouterRegistry */
private pluginRouters: Map<string, PluginRouterRegistryImpl> = new Map();
declare config: PluginConfig;
public NapCatConfig = NapCatConfig;
@ -183,6 +187,13 @@ export class OB11PluginManager extends IOB11NetworkAdapter<PluginConfig> 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<PluginConfig> impleme
adapterName: this.name,
pluginManager: this,
logger: pluginLogger,
router,
};
}
@ -391,6 +403,20 @@ export class OB11PluginManager extends IOB11NetworkAdapter<PluginConfig> impleme
return path.join(this.getPluginDataPath(pluginId), 'config.json');
}
/**
*
*/
public getPluginRouter (pluginId: string): PluginRouterRegistryImpl | undefined {
return this.pluginRouters.get(pluginId);
}
/**
*
*/
public getAllPluginRouters (): Map<string, PluginRouterRegistryImpl> {
return this.pluginRouters;
}
// ==================== 事件处理 ====================
async onEvent<T extends OB11EmitEventContent> (event: T): Promise<void> {

View File

@ -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<string, string | string[] | undefined>,
body: req.body,
headers: req.headers as Record<string, string | string[] | undefined>,
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 = [];
}
}

View File

@ -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<string, string | string[] | undefined>;
/** 请求体 */
body: unknown;
/** 请求头 */
headers: Record<string, string | string[] | undefined>;
/** 路由参数 */
params: Record<string, string>;
/** 原始请求对象(用于高级用法) */
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<void>;
/** 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<T extends OB11EmitEventContent = OB11EmitEventContent> {
export interface PluginModule<T extends OB11EmitEventContent = OB11EmitEventContent, C = unknown> {
plugin_init: (ctx: NapCatPluginContext) => void | Promise<void>;
plugin_onmessage?: (
ctx: NapCatPluginContext,
@ -143,8 +268,8 @@ export interface PluginModule<T extends OB11EmitEventContent = OB11EmitEventCont
) => void | Promise<void>;
plugin_config_schema?: PluginConfigSchema;
plugin_config_ui?: PluginConfigSchema;
plugin_get_config?: (ctx: NapCatPluginContext) => unknown | Promise<unknown>;
plugin_set_config?: (ctx: NapCatPluginContext, config: unknown) => void | Promise<void>;
plugin_get_config?: (ctx: NapCatPluginContext) => C | Promise<C>;
plugin_set_config?: (ctx: NapCatPluginContext, config: C) => void | Promise<void>;
/**
* -
*

View File

@ -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<BuiltinPluginConfig>;
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;

View File

@ -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"

View File

@ -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'],

View File

@ -0,0 +1,414 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>内置插件仪表盘</title>
<style>
:root {
--bg-primary: rgba(255, 255, 255, 0.4);
--bg-secondary: rgba(255, 255, 255, 0.6);
--bg-card: rgba(255, 255, 255, 0.5);
--bg-item: rgba(0, 0, 0, 0.03);
--text-primary: #1a1a1a;
--text-secondary: #666;
--text-muted: #999;
--border-color: rgba(0, 0, 0, 0.06);
--accent-color: #52525b;
--accent-light: rgba(82, 82, 91, 0.1);
--success-color: #17c964;
}
@media (prefers-color-scheme: dark) {
:root {
--bg-primary: rgba(0, 0, 0, 0.2);
--bg-secondary: rgba(0, 0, 0, 0.3);
--bg-card: rgba(255, 255, 255, 0.05);
--bg-item: rgba(255, 255, 255, 0.05);
--text-primary: #f5f5f5;
--text-secondary: #a1a1a1;
--text-muted: #666;
--border-color: rgba(255, 255, 255, 0.1);
--accent-light: rgba(82, 82, 91, 0.25);
}
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: transparent;
min-height: 100vh;
padding: 16px;
color: var(--text-primary);
}
.container {
max-width: 900px;
margin: 0 auto;
}
.card {
background: var(--bg-card);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-radius: 14px;
padding: 20px;
margin-bottom: 16px;
border: 1px solid var(--border-color);
}
.card-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
}
.card-header .icon {
font-size: 20px;
}
.card-header h2 {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.status-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
}
.status-item {
background: var(--bg-item);
border-radius: 12px;
padding: 16px;
text-align: center;
border: 1px solid var(--border-color);
transition: all 0.2s ease;
}
.status-item:hover {
background: var(--accent-light);
border-color: var(--accent-color);
}
.status-item .label {
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 6px;
}
.status-item .value {
font-size: 18px;
font-weight: 600;
color: var(--accent-color);
word-break: break-all;
}
.status-item .value.success {
color: var(--success-color);
}
.config-list {
list-style: none;
}
.config-list li {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
border-radius: 8px;
margin-bottom: 6px;
background: var(--bg-item);
border: 1px solid var(--border-color);
}
.config-list li:last-child {
margin-bottom: 0;
}
.config-list .key {
font-weight: 500;
font-size: 13px;
color: var(--text-secondary);
}
.config-list .value {
color: var(--accent-color);
font-family: 'Monaco', 'Consolas', monospace;
font-size: 12px;
background: var(--accent-light);
padding: 4px 10px;
border-radius: 6px;
max-width: 60%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 16px;
border: none;
border-radius: 10px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-primary {
background: var(--accent-color);
color: white;
}
.btn-primary:hover {
opacity: 0.9;
transform: scale(1.02);
}
.actions {
display: flex;
gap: 10px;
margin-top: 16px;
}
.loading {
text-align: center;
padding: 30px;
color: var(--text-muted);
font-size: 14px;
}
.loading::after {
content: '';
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid var(--accent-color);
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-left: 8px;
vertical-align: middle;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.error {
background: rgba(243, 18, 96, 0.1);
color: #f31260;
padding: 12px 16px;
border-radius: 10px;
font-size: 13px;
border: 1px solid rgba(243, 18, 96, 0.2);
}
.footer {
text-align: center;
color: var(--text-muted);
font-size: 11px;
margin-top: 16px;
padding: 8px;
}
</style>
</head>
<body>
<div class="container">
<div class="card">
<div class="card-header">
<h2>NapCat 内置插件仪表盘</h2>
</div>
<div id="content">
<div class="loading">加载中</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h2>当前配置</h2>
</div>
<div id="config-content">
<div class="loading">加载中</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h2>静态资源测试</h2>
</div>
<div id="static-content">
<p style="color: var(--text-secondary); font-size: 13px; margin-bottom: 12px;">
测试插件静态资源服务是否正常工作
</p>
<div class="actions" style="margin-top: 0;">
<button class="btn btn-primary" onclick="testStaticResource()">
获取 test.txt
</button>
</div>
<div id="static-result" style="margin-top: 12px;"></div>
</div>
</div>
<div class="footer">
<p>NapCat Builtin Plugin - WebUI 扩展页面演示</p>
</div>
</div>
<script>
// 从 URL 参数获取 webui_token
const urlParams = new URLSearchParams(window.location.search);
const webuiToken = urlParams.get('webui_token') || '';
// 插件自行管理 API 调用
const apiBase = '/api/Plugin/ext/napcat-plugin-builtin';
// 封装 fetch自动携带认证
async function authFetch (url, options = {}) {
const headers = options.headers || {};
if (webuiToken) {
headers['Authorization'] = `Bearer ${webuiToken}`;
}
return fetch(url, { ...options, headers });
}
async function fetchStatus () {
try {
const response = await authFetch(`${apiBase}/status`);
const result = await response.json();
if (result.code === 0) {
renderStatus(result.data);
} else {
showError('获取状态失败: ' + result.message);
}
} catch (error) {
showError('请求失败: ' + error.message);
}
}
async function fetchConfig () {
try {
const response = await authFetch(`${apiBase}/config`);
const result = await response.json();
if (result.code === 0) {
renderConfig(result.data);
} else {
showError('获取配置失败: ' + result.message);
}
} catch (error) {
showError('请求失败: ' + error.message);
}
}
function renderStatus (data) {
const content = document.getElementById('content');
content.innerHTML = `
<div class="status-grid">
<div class="status-item">
<div class="label">插件名称</div>
<div class="value">${data.pluginName}</div>
</div>
<div class="status-item">
<div class="label">运行时间</div>
<div class="value success">${data.uptimeFormatted}</div>
</div>
<div class="status-item">
<div class="label">运行平台</div>
<div class="value">${data.platform}</div>
</div>
<div class="status-item">
<div class="label">系统架构</div>
<div class="value">${data.arch}</div>
</div>
</div>
<div class="actions">
<button class="btn btn-primary" onclick="refresh()">
刷新状态
</button>
</div>
`;
}
function renderConfig (config) {
const content = document.getElementById('config-content');
const items = Object.entries(config)
.map(([key, value]) => `
<li>
<span class="key">${key}</span>
<span class="value">${JSON.stringify(value)}</span>
</li>
`)
.join('');
content.innerHTML = `
<ul class="config-list">
${items || '<li><span class="key">暂无配置</span></li>'}
</ul>
`;
}
function showError (message) {
const content = document.getElementById('content');
content.innerHTML = `<div class="error">${message}</div>`;
}
function refresh () {
document.getElementById('content').innerHTML = '<div class="loading">加载中</div>';
fetchStatus();
fetchConfig();
}
// 初始化
refresh();
// 每 30 秒自动刷新
setInterval(refresh, 30000);
// 测试静态资源
async function testStaticResource () {
const resultDiv = document.getElementById('static-result');
resultDiv.innerHTML = '<div class="loading">加载中</div>';
try {
const response = await authFetch(`${apiBase}/static/test.txt`);
if (response.ok) {
const text = await response.text();
resultDiv.innerHTML = `
<div style="background: var(--bg-item); border: 1px solid var(--border-color); border-radius: 8px; padding: 12px;">
<div style="font-size: 11px; color: var(--success-color); margin-bottom: 8px;">静态资源访问成功</div>
<pre style="font-family: Monaco, Consolas, monospace; font-size: 12px; color: var(--text-primary); white-space: pre-wrap; margin: 0;">${text}</pre>
</div>
`;
} else {
resultDiv.innerHTML = `<div class="error">请求失败: ${response.status} ${response.statusText}</div>`;
}
} catch (error) {
resultDiv.innerHTML = `<div class="error">请求失败: ${error.message}</div>`;
}
}
</script>
</body>
</html>

View File

@ -0,0 +1,6 @@
Hello from NapCat Builtin Plugin!
这是一个静态资源测试文件。
如果你能看到这段文字,说明插件的静态资源服务正常工作。
时间戳: 2026-01-30

View File

@ -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",

View File

@ -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) => {

View File

@ -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 {

View File

@ -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 };

View File

@ -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 () {
<Route path='terminal' element={<TerminalPage />} />
<Route path='plugins' element={<PluginPage />} />
<Route path='plugin_store' element={<PluginStorePage />} />
<Route path='extension' element={<ExtensionPage />} />
<Route path='about' element={<AboutPage />} />
</Route>
<Route path='/qq_login' element={<QQLoginPage />} />

View File

@ -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: <LuStore className='w-5 h-5' />,
href: '/plugin_store',
},
{
label: '扩展页面',
icon: <LuPuzzle className='w-5 h-5' />,
href: '/extension',
},
{
label: '系统终端',
icon: <LuTerminal className='w-5 h-5' />,

View File

@ -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[];
}
/** 插件配置项定义 */

View File

@ -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<ExtensionPage[]>([]);
const [selectedTab, setSelectedTab] = useState<string>('');
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 (
<>
<title> - NapCat WebUI</title>
<div className="p-2 md:p-4 relative h-full flex flex-col">
<PageLoading loading={loading} />
<div className="flex mb-4 items-center gap-4">
<div className="flex items-center gap-2 text-default-600">
<MdExtension size={24} />
<span className="text-lg font-medium"></span>
</div>
<Button
isIconOnly
className="bg-default-100/50 hover:bg-default-200/50 text-default-700 backdrop-blur-md"
radius="full"
onPress={refresh}
>
<IoMdRefresh size={24} />
</Button>
</div>
{extensionPages.length === 0 && !loading ? (
<div className="flex-1 flex flex-col items-center justify-center text-default-400">
<MdExtension size={64} className="mb-4 opacity-50" />
<p className="text-lg"></p>
<p className="text-sm mt-2"> WebUI </p>
</div>
) : (
<div className="flex-1 flex flex-col min-h-0">
<Tabs
aria-label="Extension Pages"
className="max-w-full"
selectedKey={selectedTab}
onSelectionChange={(key) => 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
key={tab.key}
title={
<div className="flex items-center gap-2">
{tab.icon && <span>{tab.icon}</span>}
<span>{tab.title}</span>
<span className="text-xs text-default-400">({tab.pluginName})</span>
</div>
}
>
<div className="relative w-full h-[calc(100vh-220px)] bg-white/40 dark:bg-black/20 backdrop-blur-md rounded-lg overflow-hidden">
{iframeLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-default-100/50 z-10">
<Spinner size="lg" />
</div>
)}
<iframe
src={currentPageUrl}
className="w-full h-full border-0"
onLoad={handleIframeLoad}
title={tab.title}
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
/>
</div>
</Tab>
))}
</Tabs>
</div>
)}
</div>
</>
);
}

View File

@ -232,8 +232,8 @@ importers:
packages/napcat-plugin-builtin:
dependencies:
napcat-types:
specifier: 0.0.11
version: 0.0.11
specifier: 0.0.14
version: 0.0.14
devDependencies:
'@types/node':
specifier: ^22.0.1
@ -5456,8 +5456,8 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
napcat-types@0.0.11:
resolution: {integrity: sha512-HKrhmC1oxIc3amv6gVx9wBKewBfQZXPrLHdJRvOKOx4NJdxzUXMVvE5C2em8COGcImkCq4NFRRAwZq4Bm8xDKQ==}
napcat-types@0.0.14:
resolution: {integrity: sha512-q5ke+vzzXeZkYPsr9jmj94NxgH63/xv5yS/lPEU++A3x2mOM8SYJqdFEMbHG1QIFciyH1u3qnnNiJ0mBxOBFbA==}
napcat.protobuf@1.1.4:
resolution: {integrity: sha512-z7XtLSBJ/PxmYb0VD/w+eYr/X3LyGz+SZ2QejFTOczwt6zWNxy2yV1mTMTvJoc3BWkI3ESVFRxkuT6+pj1tb1Q==}
@ -12801,7 +12801,7 @@ snapshots:
nanoid@3.3.11: {}
napcat-types@0.0.11:
napcat-types@0.0.14:
dependencies:
'@sinclair/typebox': 0.34.41
'@types/node': 22.19.1