mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-02-04 06:31:13 +00:00
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:
parent
05d27e86ce
commit
c38b98a0c4
@ -15,6 +15,7 @@ import {
|
|||||||
NapCatPluginContext,
|
NapCatPluginContext,
|
||||||
IPluginManager,
|
IPluginManager,
|
||||||
} from './plugin/types';
|
} from './plugin/types';
|
||||||
|
import { PluginRouterRegistryImpl } from './plugin/router-registry';
|
||||||
|
|
||||||
export { PluginPackageJson } from './plugin/types';
|
export { PluginPackageJson } from './plugin/types';
|
||||||
export { PluginConfigItem } from './plugin/types';
|
export { PluginConfigItem } from './plugin/types';
|
||||||
@ -25,6 +26,9 @@ export { PluginLogger } from './plugin/types';
|
|||||||
export { NapCatPluginContext } from './plugin/types';
|
export { NapCatPluginContext } from './plugin/types';
|
||||||
export { PluginModule } from './plugin/types';
|
export { PluginModule } from './plugin/types';
|
||||||
export { PluginStatusConfig } 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 {
|
export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> implements IPluginManager {
|
||||||
private readonly pluginPath: string;
|
private readonly pluginPath: string;
|
||||||
private readonly configPath: string;
|
private readonly configPath: string;
|
||||||
@ -33,6 +37,9 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> i
|
|||||||
/** 插件注册表: ID -> 插件条目 */
|
/** 插件注册表: ID -> 插件条目 */
|
||||||
private plugins: Map<string, PluginEntry> = new Map();
|
private plugins: Map<string, PluginEntry> = new Map();
|
||||||
|
|
||||||
|
/** 插件路由注册表: ID -> 路由注册器 */
|
||||||
|
private pluginRouters: Map<string, PluginRouterRegistryImpl> = new Map();
|
||||||
|
|
||||||
declare config: PluginConfig;
|
declare config: PluginConfig;
|
||||||
public NapCatConfig = NapCatConfig;
|
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.loaded = false;
|
||||||
entry.runtime = {
|
entry.runtime = {
|
||||||
@ -192,6 +206,11 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> i
|
|||||||
error: (...args: any[]) => coreLogger.logError(pluginPrefix, ...args),
|
error: (...args: any[]) => coreLogger.logError(pluginPrefix, ...args),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 创建插件路由注册器
|
||||||
|
const routerRegistry = new PluginRouterRegistryImpl(entry.id, entry.pluginPath);
|
||||||
|
// 保存到路由注册表
|
||||||
|
this.pluginRouters.set(entry.id, routerRegistry);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
core: this.core,
|
core: this.core,
|
||||||
oneBot: this.obContext,
|
oneBot: this.obContext,
|
||||||
@ -204,6 +223,7 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> i
|
|||||||
adapterName: this.name,
|
adapterName: this.name,
|
||||||
pluginManager: this,
|
pluginManager: this,
|
||||||
logger: pluginLogger,
|
logger: pluginLogger,
|
||||||
|
router: routerRegistry,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -237,6 +257,20 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> i
|
|||||||
return this.plugins.get(pluginId);
|
return this.plugins.get(pluginId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取插件路由注册器
|
||||||
|
*/
|
||||||
|
public getPluginRouter (pluginId: string): PluginRouterRegistryImpl | undefined {
|
||||||
|
return this.pluginRouters.get(pluginId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有插件路由注册器
|
||||||
|
*/
|
||||||
|
public getAllPluginRouters (): Map<string, PluginRouterRegistryImpl> {
|
||||||
|
return this.pluginRouters;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 设置插件状态(启用/禁用)
|
* 设置插件状态(启用/禁用)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { IOB11NetworkAdapter } from '@/napcat-onebot/network/adapter';
|
|||||||
import { PluginConfig } from '@/napcat-onebot/config/config';
|
import { PluginConfig } from '@/napcat-onebot/config/config';
|
||||||
import { NapCatConfig } from './config';
|
import { NapCatConfig } from './config';
|
||||||
import { PluginLoader } from './loader';
|
import { PluginLoader } from './loader';
|
||||||
|
import { PluginRouterRegistryImpl } from './router-registry';
|
||||||
import {
|
import {
|
||||||
PluginEntry,
|
PluginEntry,
|
||||||
PluginLogger,
|
PluginLogger,
|
||||||
@ -24,6 +25,9 @@ export class OB11PluginManager extends IOB11NetworkAdapter<PluginConfig> impleme
|
|||||||
/** 插件注册表: ID -> 插件条目 */
|
/** 插件注册表: ID -> 插件条目 */
|
||||||
private plugins: Map<string, PluginEntry> = new Map();
|
private plugins: Map<string, PluginEntry> = new Map();
|
||||||
|
|
||||||
|
/** 插件路由注册表: pluginId -> PluginRouterRegistry */
|
||||||
|
private pluginRouters: Map<string, PluginRouterRegistryImpl> = new Map();
|
||||||
|
|
||||||
declare config: PluginConfig;
|
declare config: PluginConfig;
|
||||||
public NapCatConfig = NapCatConfig;
|
public NapCatConfig = NapCatConfig;
|
||||||
|
|
||||||
@ -183,6 +187,13 @@ export class OB11PluginManager extends IOB11NetworkAdapter<PluginConfig> impleme
|
|||||||
error: (...args: any[]) => coreLogger.logError(pluginPrefix, ...args),
|
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 {
|
return {
|
||||||
core: this.core,
|
core: this.core,
|
||||||
oneBot: this.obContext,
|
oneBot: this.obContext,
|
||||||
@ -195,6 +206,7 @@ export class OB11PluginManager extends IOB11NetworkAdapter<PluginConfig> impleme
|
|||||||
adapterName: this.name,
|
adapterName: this.name,
|
||||||
pluginManager: this,
|
pluginManager: this,
|
||||||
logger: pluginLogger,
|
logger: pluginLogger,
|
||||||
|
router,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -391,6 +403,20 @@ export class OB11PluginManager extends IOB11NetworkAdapter<PluginConfig> impleme
|
|||||||
return path.join(this.getPluginDataPath(pluginId), 'config.json');
|
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> {
|
async onEvent<T extends OB11EmitEventContent> (event: T): Promise<void> {
|
||||||
|
|||||||
221
packages/napcat-onebot/network/plugin/router-registry.ts
Normal file
221
packages/napcat-onebot/network/plugin/router-registry.ts
Normal 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 = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@ import { NapCatCore } from 'napcat-core';
|
|||||||
import { NapCatOneBot11Adapter, OB11Message } from '@/napcat-onebot/index';
|
import { NapCatOneBot11Adapter, OB11Message } from '@/napcat-onebot/index';
|
||||||
import { ActionMap } from '@/napcat-onebot/action';
|
import { ActionMap } from '@/napcat-onebot/action';
|
||||||
import { OB11EmitEventContent } from '@/napcat-onebot/network/index';
|
import { OB11EmitEventContent } from '@/napcat-onebot/network/index';
|
||||||
|
import { NetworkAdapterConfig } from '@/napcat-onebot/config/config';
|
||||||
|
|
||||||
// ==================== 插件包信息 ====================
|
// ==================== 插件包信息 ====================
|
||||||
|
|
||||||
@ -49,11 +50,130 @@ export interface INapCatConfigStatic {
|
|||||||
/** NapCatConfig 类型(包含静态方法) */
|
/** NapCatConfig 类型(包含静态方法) */
|
||||||
export type NapCatConfigClass = INapCatConfigStatic;
|
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 {
|
export interface IPluginManager {
|
||||||
readonly config: unknown;
|
readonly config: NetworkAdapterConfig;
|
||||||
getPluginPath (): string;
|
getPluginPath (): string;
|
||||||
getPluginConfig (): PluginStatusConfig;
|
getPluginConfig (): PluginStatusConfig;
|
||||||
getAllPlugins (): PluginEntry[];
|
getAllPlugins (): PluginEntry[];
|
||||||
@ -124,11 +244,16 @@ export interface NapCatPluginContext {
|
|||||||
pluginManager: IPluginManager;
|
pluginManager: IPluginManager;
|
||||||
/** 插件日志器 - 自动添加插件名称前缀 */
|
/** 插件日志器 - 自动添加插件名称前缀 */
|
||||||
logger: PluginLogger;
|
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_init: (ctx: NapCatPluginContext) => void | Promise<void>;
|
||||||
plugin_onmessage?: (
|
plugin_onmessage?: (
|
||||||
ctx: NapCatPluginContext,
|
ctx: NapCatPluginContext,
|
||||||
@ -143,8 +268,8 @@ export interface PluginModule<T extends OB11EmitEventContent = OB11EmitEventCont
|
|||||||
) => void | Promise<void>;
|
) => void | Promise<void>;
|
||||||
plugin_config_schema?: PluginConfigSchema;
|
plugin_config_schema?: PluginConfigSchema;
|
||||||
plugin_config_ui?: PluginConfigSchema;
|
plugin_config_ui?: PluginConfigSchema;
|
||||||
plugin_get_config?: (ctx: NapCatPluginContext) => unknown | Promise<unknown>;
|
plugin_get_config?: (ctx: NapCatPluginContext) => C | Promise<C>;
|
||||||
plugin_set_config?: (ctx: NapCatPluginContext, config: unknown) => void | Promise<void>;
|
plugin_set_config?: (ctx: NapCatPluginContext, config: C) => void | Promise<void>;
|
||||||
/**
|
/**
|
||||||
* 配置界面控制器 - 当配置界面打开时调用
|
* 配置界面控制器 - 当配置界面打开时调用
|
||||||
* 返回清理函数,在界面关闭时调用
|
* 返回清理函数,在界面关闭时调用
|
||||||
|
|||||||
@ -63,14 +63,68 @@ const plugin_init: PluginModule['plugin_init'] = async (ctx) => {
|
|||||||
logger?.warn('Failed to load config', e);
|
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 () => {
|
export const plugin_get_config: PluginModule['plugin_get_config'] = async () => {
|
||||||
return currentConfig;
|
return currentConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const plugin_set_config: PluginModule['plugin_set_config'] = async (ctx, config: BuiltinPluginConfig) => {
|
export const plugin_set_config: PluginModule['plugin_set_config'] = async (ctx, config) => {
|
||||||
currentConfig = config;
|
currentConfig = config as BuiltinPluginConfig;
|
||||||
if (ctx && ctx.configPath) {
|
if (ctx && ctx.configPath) {
|
||||||
try {
|
try {
|
||||||
const configPath = ctx.configPath;
|
const configPath = ctx.configPath;
|
||||||
|
|||||||
@ -7,7 +7,7 @@
|
|||||||
"description": "NapCat 内置插件",
|
"description": "NapCat 内置插件",
|
||||||
"author": "NapNeko",
|
"author": "NapNeko",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"napcat-types": "0.0.11"
|
"napcat-types": "0.0.14"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.0.1"
|
"@types/node": "^22.0.1"
|
||||||
|
|||||||
@ -14,8 +14,10 @@ function copyToShellPlugin () {
|
|||||||
writeBundle () {
|
writeBundle () {
|
||||||
try {
|
try {
|
||||||
const sourceDir = resolve(__dirname, 'dist');
|
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 packageJsonSource = resolve(__dirname, 'package.json');
|
||||||
|
const webuiSourceDir = resolve(__dirname, 'webui');
|
||||||
|
const webuiTargetDir = resolve(targetDir, 'webui');
|
||||||
|
|
||||||
// 确保目标目录存在
|
// 确保目标目录存在
|
||||||
if (!fs.existsSync(targetDir)) {
|
if (!fs.existsSync(targetDir)) {
|
||||||
@ -44,6 +46,12 @@ function copyToShellPlugin () {
|
|||||||
copiedCount++;
|
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}`);
|
console.log(`[copy-to-shell] Successfully copied ${copiedCount} file(s) to ${targetDir}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[copy-to-shell] Failed to copy files:', 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({
|
export default defineConfig({
|
||||||
resolve: {
|
resolve: {
|
||||||
conditions: ['node', 'default'],
|
conditions: ['node', 'default'],
|
||||||
|
|||||||
414
packages/napcat-plugin-builtin/webui/dashboard.html
Normal file
414
packages/napcat-plugin-builtin/webui/dashboard.html
Normal 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>
|
||||||
6
packages/napcat-plugin-builtin/webui/test.txt
Normal file
6
packages/napcat-plugin-builtin/webui/test.txt
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
Hello from NapCat Builtin Plugin!
|
||||||
|
|
||||||
|
这是一个静态资源测试文件。
|
||||||
|
如果你能看到这段文字,说明插件的静态资源服务正常工作。
|
||||||
|
|
||||||
|
时间戳: 2026-01-30
|
||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "napcat-types",
|
"name": "napcat-types",
|
||||||
"version": "0.0.11",
|
"version": "0.0.14",
|
||||||
"private": false,
|
"private": false,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"types": "./napcat-types/index.d.ts",
|
"types": "./napcat-types/index.d.ts",
|
||||||
|
|||||||
@ -62,7 +62,7 @@ export const GetPluginListHandler: RequestHandler = async (_req, res) => {
|
|||||||
const pluginManager = getPluginManager();
|
const pluginManager = getPluginManager();
|
||||||
if (!pluginManager) {
|
if (!pluginManager) {
|
||||||
// 返回成功但带特殊标记
|
// 返回成功但带特殊标记
|
||||||
return sendSuccess(res, { plugins: [], pluginManagerNotFound: true });
|
return sendSuccess(res, { plugins: [], pluginManagerNotFound: true, extensionPages: [] });
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadedPlugins = pluginManager.getAllPlugins();
|
const loadedPlugins = pluginManager.getAllPlugins();
|
||||||
@ -74,8 +74,19 @@ export const GetPluginListHandler: RequestHandler = async (_req, res) => {
|
|||||||
author: string;
|
author: string;
|
||||||
status: string;
|
status: string;
|
||||||
hasConfig: boolean;
|
hasConfig: boolean;
|
||||||
|
hasPages: boolean;
|
||||||
}> = new Array();
|
}> = new Array();
|
||||||
|
|
||||||
|
// 收集所有插件的扩展页面
|
||||||
|
const extensionPages: Array<{
|
||||||
|
pluginId: string;
|
||||||
|
pluginName: string;
|
||||||
|
path: string;
|
||||||
|
title: string;
|
||||||
|
icon?: string;
|
||||||
|
description?: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
// 1. 整理已加载的插件
|
// 1. 整理已加载的插件
|
||||||
for (const p of loadedPlugins) {
|
for (const p of loadedPlugins) {
|
||||||
// 根据插件状态确定 status
|
// 根据插件状态确定 status
|
||||||
@ -88,6 +99,10 @@ export const GetPluginListHandler: RequestHandler = async (_req, res) => {
|
|||||||
status = 'stopped'; // 启用但未加载(可能加载失败)
|
status = 'stopped'; // 启用但未加载(可能加载失败)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查插件是否有注册页面
|
||||||
|
const pluginRouter = pluginManager.getPluginRouter(p.id);
|
||||||
|
const hasPages = pluginRouter?.hasPages() ?? false;
|
||||||
|
|
||||||
AllPlugins.push({
|
AllPlugins.push({
|
||||||
name: p.packageJson?.plugin || p.name || '', // 优先显示 package.json 的 plugin 字段
|
name: p.packageJson?.plugin || p.name || '', // 优先显示 package.json 的 plugin 字段
|
||||||
id: p.id, // 包名,用于 API 操作
|
id: p.id, // 包名,用于 API 操作
|
||||||
@ -95,12 +110,28 @@ export const GetPluginListHandler: RequestHandler = async (_req, res) => {
|
|||||||
description: p.packageJson?.description || '',
|
description: p.packageJson?.description || '',
|
||||||
author: p.packageJson?.author || '',
|
author: p.packageJson?.author || '',
|
||||||
status,
|
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) => {
|
export const SetPluginStatusHandler: RequestHandler = async (req, res) => {
|
||||||
|
|||||||
@ -16,10 +16,7 @@ export async function auth (req: Request, res: Response, next: NextFunction) {
|
|||||||
req.url === '/auth/passkey/verify-authentication') {
|
req.url === '/auth/passkey/verify-authentication') {
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
let hash: string | undefined;
|
||||||
|
|
||||||
|
|
||||||
// 判断是否有Authorization头
|
|
||||||
if (req.headers?.authorization) {
|
if (req.headers?.authorization) {
|
||||||
// 切割参数以获取token
|
// 切割参数以获取token
|
||||||
const authorization = req.headers.authorization.split(' ');
|
const authorization = req.headers.authorization.split(' ');
|
||||||
@ -28,8 +25,14 @@ export async function auth (req: Request, res: Response, next: NextFunction) {
|
|||||||
return sendError(res, 'Unauthorized');
|
return sendError(res, 'Unauthorized');
|
||||||
}
|
}
|
||||||
// 获取token
|
// 获取token
|
||||||
const hash = authorization[1];
|
hash = authorization[1];
|
||||||
if (!hash) return sendError(res, 'Unauthorized');
|
} 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
|
// 解析token
|
||||||
let Credential: WebUiCredentialJson;
|
let Credential: WebUiCredentialJson;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -20,6 +20,9 @@ import {
|
|||||||
InstallPluginFromStoreHandler,
|
InstallPluginFromStoreHandler,
|
||||||
InstallPluginFromStoreSSEHandler
|
InstallPluginFromStoreSSEHandler
|
||||||
} from '@/napcat-webui-backend/src/api/PluginStore';
|
} 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 用于文件上传
|
// 配置 multer 用于文件上传
|
||||||
const uploadDir = path.join(os.tmpdir(), 'napcat-plugin-uploads');
|
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.post('/Store/Install', InstallPluginFromStoreHandler);
|
||||||
router.get('/Store/Install/SSE', InstallPluginFromStoreSSEHandler);
|
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 };
|
export { router as PluginRouter };
|
||||||
|
|||||||
@ -27,6 +27,7 @@ const NetworkPage = lazy(() => import('@/pages/dashboard/network'));
|
|||||||
const TerminalPage = lazy(() => import('@/pages/dashboard/terminal'));
|
const TerminalPage = lazy(() => import('@/pages/dashboard/terminal'));
|
||||||
const PluginPage = lazy(() => import('@/pages/dashboard/plugin'));
|
const PluginPage = lazy(() => import('@/pages/dashboard/plugin'));
|
||||||
const PluginStorePage = lazy(() => import('@/pages/dashboard/plugin_store'));
|
const PluginStorePage = lazy(() => import('@/pages/dashboard/plugin_store'));
|
||||||
|
const ExtensionPage = lazy(() => import('@/pages/dashboard/extension'));
|
||||||
|
|
||||||
function App () {
|
function App () {
|
||||||
return (
|
return (
|
||||||
@ -80,6 +81,7 @@ function AppRoutes () {
|
|||||||
<Route path='terminal' element={<TerminalPage />} />
|
<Route path='terminal' element={<TerminalPage />} />
|
||||||
<Route path='plugins' element={<PluginPage />} />
|
<Route path='plugins' element={<PluginPage />} />
|
||||||
<Route path='plugin_store' element={<PluginStorePage />} />
|
<Route path='plugin_store' element={<PluginStorePage />} />
|
||||||
|
<Route path='extension' element={<ExtensionPage />} />
|
||||||
<Route path='about' element={<AboutPage />} />
|
<Route path='about' element={<AboutPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path='/qq_login' element={<QQLoginPage />} />
|
<Route path='/qq_login' element={<QQLoginPage />} />
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
LuZap,
|
LuZap,
|
||||||
LuPackage,
|
LuPackage,
|
||||||
LuStore,
|
LuStore,
|
||||||
|
LuPuzzle,
|
||||||
} from 'react-icons/lu';
|
} from 'react-icons/lu';
|
||||||
|
|
||||||
export type SiteConfig = typeof siteConfig;
|
export type SiteConfig = typeof siteConfig;
|
||||||
@ -66,6 +67,11 @@ export const siteConfig = {
|
|||||||
icon: <LuStore className='w-5 h-5' />,
|
icon: <LuStore className='w-5 h-5' />,
|
||||||
href: '/plugin_store',
|
href: '/plugin_store',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: '扩展页面',
|
||||||
|
icon: <LuPuzzle className='w-5 h-5' />,
|
||||||
|
href: '/extension',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: '系统终端',
|
label: '系统终端',
|
||||||
icon: <LuTerminal className='w-5 h-5' />,
|
icon: <LuTerminal className='w-5 h-5' />,
|
||||||
|
|||||||
@ -20,12 +20,31 @@ export interface PluginItem {
|
|||||||
status: PluginStatus;
|
status: PluginStatus;
|
||||||
/** 是否有配置项 */
|
/** 是否有配置项 */
|
||||||
hasConfig?: boolean;
|
hasConfig?: boolean;
|
||||||
|
/** 是否有扩展页面 */
|
||||||
|
hasPages?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 扩展页面信息 */
|
||||||
|
export interface ExtensionPageItem {
|
||||||
|
/** 插件 ID */
|
||||||
|
pluginId: string;
|
||||||
|
/** 插件名称 */
|
||||||
|
pluginName: string;
|
||||||
|
/** 页面路径 */
|
||||||
|
path: string;
|
||||||
|
/** 页面标题 */
|
||||||
|
title: string;
|
||||||
|
/** 页面图标 */
|
||||||
|
icon?: string;
|
||||||
|
/** 页面描述 */
|
||||||
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 插件列表响应 */
|
/** 插件列表响应 */
|
||||||
export interface PluginListResponse {
|
export interface PluginListResponse {
|
||||||
plugins: PluginItem[];
|
plugins: PluginItem[];
|
||||||
pluginManagerNotFound: boolean;
|
pluginManagerNotFound: boolean;
|
||||||
|
extensionPages: ExtensionPageItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 插件配置项定义 */
|
/** 插件配置项定义 */
|
||||||
|
|||||||
162
packages/napcat-webui-frontend/src/pages/dashboard/extension.tsx
Normal file
162
packages/napcat-webui-frontend/src/pages/dashboard/extension.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -232,8 +232,8 @@ importers:
|
|||||||
packages/napcat-plugin-builtin:
|
packages/napcat-plugin-builtin:
|
||||||
dependencies:
|
dependencies:
|
||||||
napcat-types:
|
napcat-types:
|
||||||
specifier: 0.0.11
|
specifier: 0.0.14
|
||||||
version: 0.0.11
|
version: 0.0.14
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.0.1
|
specifier: ^22.0.1
|
||||||
@ -5456,8 +5456,8 @@ packages:
|
|||||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
napcat-types@0.0.11:
|
napcat-types@0.0.14:
|
||||||
resolution: {integrity: sha512-HKrhmC1oxIc3amv6gVx9wBKewBfQZXPrLHdJRvOKOx4NJdxzUXMVvE5C2em8COGcImkCq4NFRRAwZq4Bm8xDKQ==}
|
resolution: {integrity: sha512-q5ke+vzzXeZkYPsr9jmj94NxgH63/xv5yS/lPEU++A3x2mOM8SYJqdFEMbHG1QIFciyH1u3qnnNiJ0mBxOBFbA==}
|
||||||
|
|
||||||
napcat.protobuf@1.1.4:
|
napcat.protobuf@1.1.4:
|
||||||
resolution: {integrity: sha512-z7XtLSBJ/PxmYb0VD/w+eYr/X3LyGz+SZ2QejFTOczwt6zWNxy2yV1mTMTvJoc3BWkI3ESVFRxkuT6+pj1tb1Q==}
|
resolution: {integrity: sha512-z7XtLSBJ/PxmYb0VD/w+eYr/X3LyGz+SZ2QejFTOczwt6zWNxy2yV1mTMTvJoc3BWkI3ESVFRxkuT6+pj1tb1Q==}
|
||||||
@ -12801,7 +12801,7 @@ snapshots:
|
|||||||
|
|
||||||
nanoid@3.3.11: {}
|
nanoid@3.3.11: {}
|
||||||
|
|
||||||
napcat-types@0.0.11:
|
napcat-types@0.0.14:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@sinclair/typebox': 0.34.41
|
'@sinclair/typebox': 0.34.41
|
||||||
'@types/node': 22.19.1
|
'@types/node': 22.19.1
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user