mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-02-05 15:11:15 +00:00
Add reactive plugin config UI with SSE support
Introduces a reactive plugin configuration system with dynamic schema updates via server-sent events (SSE). Adds new fields and controller interfaces to the plugin manager, updates the built-in plugin to demonstrate dynamic config fields, and implements backend and frontend logic for real-time config UI updates. Also updates napcat-types to 0.0.10.
This commit is contained in:
parent
9f62570fc2
commit
a4a93c520f
@ -24,23 +24,45 @@ export interface PluginConfigItem {
|
||||
default?: any;
|
||||
options?: { label: string; value: string | number; }[];
|
||||
placeholder?: string;
|
||||
/** 标记此字段为响应式:值变化时触发 schema 刷新 */
|
||||
reactive?: boolean;
|
||||
/** 是否隐藏此字段 */
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
/** 插件配置 UI 控制器 - 用于动态控制配置界面 */
|
||||
export interface PluginConfigUIController {
|
||||
/** 更新整个 schema */
|
||||
updateSchema: (schema: PluginConfigSchema) => void;
|
||||
/** 更新单个字段 */
|
||||
updateField: (key: string, field: Partial<PluginConfigItem>) => void;
|
||||
/** 移除字段 */
|
||||
removeField: (key: string) => void;
|
||||
/** 添加字段 */
|
||||
addField: (field: PluginConfigItem, afterKey?: string) => void;
|
||||
/** 显示字段 */
|
||||
showField: (key: string) => void;
|
||||
/** 隐藏字段 */
|
||||
hideField: (key: string) => void;
|
||||
/** 获取当前配置值 */
|
||||
getCurrentConfig: () => Record<string, any>;
|
||||
}
|
||||
|
||||
export class NapCatConfig {
|
||||
static text (key: string, label: string, defaultValue?: string, description?: string): PluginConfigItem {
|
||||
return { key, type: 'string', label, default: defaultValue, description };
|
||||
static text (key: string, label: string, defaultValue?: string, description?: string, reactive?: boolean): PluginConfigItem {
|
||||
return { key, type: 'string', label, default: defaultValue, description, reactive };
|
||||
}
|
||||
static number (key: string, label: string, defaultValue?: number, description?: string): PluginConfigItem {
|
||||
return { key, type: 'number', label, default: defaultValue, description };
|
||||
static number (key: string, label: string, defaultValue?: number, description?: string, reactive?: boolean): PluginConfigItem {
|
||||
return { key, type: 'number', label, default: defaultValue, description, reactive };
|
||||
}
|
||||
static boolean (key: string, label: string, defaultValue?: boolean, description?: string): PluginConfigItem {
|
||||
return { key, type: 'boolean', label, default: defaultValue, description };
|
||||
static boolean (key: string, label: string, defaultValue?: boolean, description?: string, reactive?: boolean): PluginConfigItem {
|
||||
return { key, type: 'boolean', label, default: defaultValue, description, reactive };
|
||||
}
|
||||
static select (key: string, label: string, options: { label: string; value: string | number; }[], defaultValue?: string | number, description?: string): PluginConfigItem {
|
||||
return { key, type: 'select', label, options, default: defaultValue, description };
|
||||
static select (key: string, label: string, options: { label: string; value: string | number; }[], defaultValue?: string | number, description?: string, reactive?: boolean): PluginConfigItem {
|
||||
return { key, type: 'select', label, options, default: defaultValue, description, reactive };
|
||||
}
|
||||
static multiSelect (key: string, label: string, options: { label: string; value: string | number; }[], defaultValue?: (string | number)[], description?: string): PluginConfigItem {
|
||||
return { key, type: 'multi-select', label, options, default: defaultValue, description };
|
||||
static multiSelect (key: string, label: string, options: { label: string; value: string | number; }[], defaultValue?: (string | number)[], description?: string, reactive?: boolean): PluginConfigItem {
|
||||
return { key, type: 'multi-select', label, options, default: defaultValue, description, reactive };
|
||||
}
|
||||
static html (content: string): PluginConfigItem {
|
||||
return { key: `_html_${Math.random().toString(36).slice(2)}`, type: 'html', label: '', default: content };
|
||||
@ -103,6 +125,25 @@ export interface PluginModule<T extends OB11EmitEventContent = OB11EmitEventCont
|
||||
plugin_config_ui?: PluginConfigSchema;
|
||||
plugin_get_config?: (ctx: NapCatPluginContext) => any | Promise<any>;
|
||||
plugin_set_config?: (ctx: NapCatPluginContext, config: any) => void | Promise<void>;
|
||||
/**
|
||||
* 配置界面控制器 - 当配置界面打开时调用
|
||||
* 返回清理函数,在界面关闭时调用
|
||||
*/
|
||||
plugin_config_controller?: (
|
||||
ctx: NapCatPluginContext,
|
||||
ui: PluginConfigUIController,
|
||||
initialConfig: Record<string, any>
|
||||
) => void | (() => void) | Promise<void | (() => void)>;
|
||||
/**
|
||||
* 响应式字段变化回调 - 当标记为 reactive 的字段值变化时调用
|
||||
*/
|
||||
plugin_on_config_change?: (
|
||||
ctx: NapCatPluginContext,
|
||||
ui: PluginConfigUIController,
|
||||
key: string,
|
||||
value: any,
|
||||
currentConfig: Record<string, any>
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface LoadedPlugin {
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
import type { ActionMap } from 'napcat-types/napcat-onebot/action/index';
|
||||
import { EventType } from 'napcat-types/napcat-onebot/event/index';
|
||||
import type { PluginModule, PluginLogger, PluginConfigSchema } from 'napcat-types/napcat-onebot/network/plugin-manger';
|
||||
import type { PluginModule, PluginLogger, PluginConfigSchema, PluginConfigUIController } from 'napcat-types/napcat-onebot/network/plugin-manger';
|
||||
import type { OB11Message, OB11PostSendMsg } from 'napcat-types/napcat-onebot/types/index';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { NetworkAdapterConfig } from 'napcat-types/napcat-onebot/config/config';
|
||||
|
||||
|
||||
let startTime: number = Date.now();
|
||||
let logger: PluginLogger | null = null;
|
||||
|
||||
@ -14,6 +16,8 @@ interface BuiltinPluginConfig {
|
||||
description: string;
|
||||
theme?: string;
|
||||
features?: string[];
|
||||
apiUrl?: string;
|
||||
apiEndpoints?: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@ -30,9 +34,11 @@ const plugin_init: PluginModule['plugin_init'] = async (ctx) => {
|
||||
logger = ctx.logger;
|
||||
logger.info('NapCat 内置插件已初始化');
|
||||
plugin_config_ui = ctx.NapCatConfig.combine(
|
||||
ctx.NapCatConfig.html('<div style="padding: 10px; background: rgba(0,0,0,0.05); border-radius: 8px;"><h3>👋 Welcome to NapCat Builtin Plugin</h3><p>This is a demonstration of the plugin configuration interface.</p></div>'),
|
||||
ctx.NapCatConfig.html('<div style="padding: 10px; background: rgba(0,0,0,0.05); border-radius: 8px;"><h3>👋 Welcome to NapCat Builtin Plugin</h3><p>This is a demonstration of the plugin configuration interface with reactive fields.</p></div>'),
|
||||
ctx.NapCatConfig.text('prefix', 'Command Prefix', '#napcat', 'The prefix to trigger the version info command'),
|
||||
ctx.NapCatConfig.boolean('enableReply', 'Enable Reply', true, 'Switch to enable or disable the reply functionality'),
|
||||
// 代表监听 apiUrl 字段的变化
|
||||
{ ...ctx.NapCatConfig.text('apiUrl', 'API URL', '', 'Enter an API URL to load available endpoints'), reactive: true },
|
||||
ctx.NapCatConfig.select('theme', 'Theme Selection', [
|
||||
{ label: 'Light Mode', value: 'light' },
|
||||
{ label: 'Dark Mode', value: 'dark' },
|
||||
@ -59,11 +65,11 @@ const plugin_init: PluginModule['plugin_init'] = async (ctx) => {
|
||||
|
||||
};
|
||||
|
||||
export const plugin_get_config = async () => {
|
||||
export const plugin_get_config: PluginModule['plugin_get_config'] = async () => {
|
||||
return currentConfig;
|
||||
};
|
||||
|
||||
export const plugin_set_config = async (ctx: any, config: BuiltinPluginConfig) => {
|
||||
export const plugin_set_config: PluginModule['plugin_set_config'] = async (ctx, config: BuiltinPluginConfig) => {
|
||||
currentConfig = config;
|
||||
if (ctx && ctx.configPath) {
|
||||
try {
|
||||
@ -80,6 +86,74 @@ export const plugin_set_config = async (ctx: any, config: BuiltinPluginConfig) =
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 响应式配置控制器 - 当插件配置界面打开时调用
|
||||
* 用于初始化动态 UI 控制
|
||||
*/
|
||||
export const plugin_config_controller: PluginModule['plugin_config_controller'] = async (_ctx, ui, initialConfig) => {
|
||||
logger?.info('配置控制器已初始化', initialConfig);
|
||||
|
||||
// 如果初始配置中有 apiUrl,立即加载端点
|
||||
if (initialConfig['apiUrl']) {
|
||||
await loadEndpointsForUrl(ui, initialConfig['apiUrl'] as string);
|
||||
}
|
||||
|
||||
// 返回清理函数
|
||||
return () => {
|
||||
logger?.info('配置控制器已清理');
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 响应式字段变化处理 - 当标记为 reactive 的字段值变化时调用
|
||||
*/
|
||||
export const plugin_on_config_change: PluginModule['plugin_on_config_change'] = async (_ctx, ui, key, value, _currentConfig: Partial<BuiltinPluginConfig>) => {
|
||||
logger?.info(`配置字段变化: ${key} = ${value}`);
|
||||
|
||||
if (key === 'apiUrl') {
|
||||
await loadEndpointsForUrl(ui, value as string);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据 API URL 动态加载端点列表
|
||||
*/
|
||||
async function loadEndpointsForUrl (ui: PluginConfigUIController, apiUrl: string) {
|
||||
if (!apiUrl) {
|
||||
// URL 为空时,移除端点选择字段
|
||||
ui.removeField('apiEndpoints');
|
||||
return;
|
||||
}
|
||||
|
||||
// 模拟从 API 获取端点列表(实际使用时可以 fetch 真实 API)
|
||||
const mockEndpoints = [
|
||||
{ label: `${apiUrl}/users`, value: '/users' },
|
||||
{ label: `${apiUrl}/posts`, value: '/posts' },
|
||||
{ label: `${apiUrl}/comments`, value: '/comments' },
|
||||
{ label: `${apiUrl}/albums`, value: '/albums' },
|
||||
];
|
||||
|
||||
// 动态添加或更新端点选择字段
|
||||
const currentSchema = ui.getCurrentConfig();
|
||||
if ('apiEndpoints' in currentSchema) {
|
||||
// 更新现有字段的选项
|
||||
ui.updateField('apiEndpoints', {
|
||||
options: mockEndpoints,
|
||||
description: `从 ${apiUrl} 加载的端点`
|
||||
});
|
||||
} else {
|
||||
// 添加新字段
|
||||
ui.addField({
|
||||
key: 'apiEndpoints',
|
||||
type: 'multi-select',
|
||||
label: 'API Endpoints',
|
||||
description: `从 ${apiUrl} 加载的端点`,
|
||||
options: mockEndpoints,
|
||||
default: []
|
||||
}, 'apiUrl');
|
||||
}
|
||||
}
|
||||
|
||||
const plugin_onmessage: PluginModule['plugin_onmessage'] = async (_ctx, event) => {
|
||||
if (currentConfig.enableReply === false) {
|
||||
return;
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
"description": "NapCat 内置插件",
|
||||
"author": "NapNeko",
|
||||
"dependencies": {
|
||||
"napcat-types": "0.0.9"
|
||||
"napcat-types": "0.0.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.1"
|
||||
|
||||
@ -181,6 +181,7 @@ export const UninstallPluginHandler: RequestHandler = async (req, res) => {
|
||||
|
||||
export const GetPluginConfigHandler: RequestHandler = async (req, res) => {
|
||||
const id = req.query['id'] as string;
|
||||
|
||||
if (!id) return sendError(res, 'Plugin id is required');
|
||||
|
||||
const pluginManager = getPluginManager();
|
||||
@ -189,18 +190,15 @@ export const GetPluginConfigHandler: RequestHandler = async (req, res) => {
|
||||
const plugin = pluginManager.getPluginInfo(id);
|
||||
if (!plugin) return sendError(res, 'Plugin not loaded');
|
||||
|
||||
// Support legacy schema or new API
|
||||
const schema = plugin.module.plugin_config_schema || plugin.module.plugin_config_ui;
|
||||
// 获取配置值
|
||||
let config = {};
|
||||
|
||||
if (plugin.module.plugin_get_config) {
|
||||
try {
|
||||
config = await plugin.module.plugin_get_config(plugin.context);
|
||||
} catch (e) { }
|
||||
} else if (schema) {
|
||||
} else {
|
||||
// Default behavior: read from default config path
|
||||
try {
|
||||
// Use context configPath if available
|
||||
const configPath = plugin.context?.configPath || pluginManager.getPluginConfigPath(id);
|
||||
if (fs.existsSync(configPath)) {
|
||||
config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
@ -208,7 +206,212 @@ export const GetPluginConfigHandler: RequestHandler = async (req, res) => {
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
return sendSuccess(res, { schema, config });
|
||||
// 获取静态 schema
|
||||
const schema = plugin.module.plugin_config_schema || plugin.module.plugin_config_ui || [];
|
||||
|
||||
// 检查是否支持动态控制
|
||||
const supportReactive = !!(plugin.module.plugin_config_controller || plugin.module.plugin_on_config_change);
|
||||
|
||||
return sendSuccess(res, { schema, config, supportReactive });
|
||||
};
|
||||
|
||||
/** 活跃的 SSE 连接 */
|
||||
const activeConfigSessions = new Map<string, {
|
||||
res: any;
|
||||
cleanup?: () => void;
|
||||
currentConfig: Record<string, any>;
|
||||
}>();
|
||||
|
||||
/**
|
||||
* 插件配置 SSE 连接 - 用于动态更新配置界面
|
||||
*/
|
||||
export const PluginConfigSSEHandler: RequestHandler = (req, res): void => {
|
||||
const id = req.query['id'] as string;
|
||||
const initialConfigStr = req.query['config'] as string;
|
||||
|
||||
if (!id) {
|
||||
res.status(400).json({ error: 'Plugin id is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const pluginManager = getPluginManager();
|
||||
if (!pluginManager) {
|
||||
res.status(400).json({ error: 'Plugin Manager not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const plugin = pluginManager.getPluginInfo(id);
|
||||
if (!plugin) {
|
||||
res.status(400).json({ error: 'Plugin not loaded' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 设置 SSE 头
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.setHeader('X-Accel-Buffering', 'no');
|
||||
res.flushHeaders();
|
||||
|
||||
// 生成会话 ID
|
||||
const sessionId = `${id}_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
||||
|
||||
// 解析初始配置
|
||||
let currentConfig: Record<string, any> = {};
|
||||
if (initialConfigStr) {
|
||||
try {
|
||||
currentConfig = JSON.parse(initialConfigStr);
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
// 发送 SSE 消息的辅助函数
|
||||
const sendSSE = (event: string, data: any) => {
|
||||
res.write(`event: ${event}\n`);
|
||||
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
|
||||
// 创建 UI 控制器
|
||||
const uiController = {
|
||||
updateSchema: (schema: any[]) => {
|
||||
sendSSE('schema', { type: 'full', schema });
|
||||
},
|
||||
updateField: (key: string, field: any) => {
|
||||
sendSSE('schema', { type: 'updateField', key, field });
|
||||
},
|
||||
removeField: (key: string) => {
|
||||
sendSSE('schema', { type: 'removeField', key });
|
||||
},
|
||||
addField: (field: any, afterKey?: string) => {
|
||||
sendSSE('schema', { type: 'addField', field, afterKey });
|
||||
},
|
||||
showField: (key: string) => {
|
||||
sendSSE('schema', { type: 'showField', key });
|
||||
},
|
||||
hideField: (key: string) => {
|
||||
sendSSE('schema', { type: 'hideField', key });
|
||||
},
|
||||
getCurrentConfig: () => currentConfig
|
||||
};
|
||||
|
||||
// 存储会话
|
||||
activeConfigSessions.set(sessionId, { res, currentConfig });
|
||||
|
||||
// 发送连接成功消息
|
||||
sendSSE('connected', { sessionId });
|
||||
|
||||
// 调用插件的控制器初始化(异步处理)
|
||||
(async () => {
|
||||
let cleanup: (() => void) | undefined;
|
||||
if (plugin.module.plugin_config_controller) {
|
||||
try {
|
||||
const result = await plugin.module.plugin_config_controller(
|
||||
plugin.context,
|
||||
uiController,
|
||||
currentConfig
|
||||
);
|
||||
if (typeof result === 'function') {
|
||||
cleanup = result;
|
||||
}
|
||||
} catch (e: any) {
|
||||
sendSSE('error', { message: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 更新会话的 cleanup
|
||||
const session = activeConfigSessions.get(sessionId);
|
||||
if (session) {
|
||||
session.cleanup = cleanup;
|
||||
}
|
||||
})();
|
||||
|
||||
// 心跳保持连接
|
||||
const heartbeat = setInterval(() => {
|
||||
sendSSE('ping', { time: Date.now() });
|
||||
}, 30000);
|
||||
|
||||
// 连接关闭时清理
|
||||
req.on('close', () => {
|
||||
clearInterval(heartbeat);
|
||||
const session = activeConfigSessions.get(sessionId);
|
||||
if (session?.cleanup) {
|
||||
try {
|
||||
session.cleanup();
|
||||
} catch (e) { }
|
||||
}
|
||||
activeConfigSessions.delete(sessionId);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 插件配置字段变化通知
|
||||
*/
|
||||
export const PluginConfigChangeHandler: RequestHandler = async (req, res) => {
|
||||
const { id, sessionId, key, value, currentConfig } = req.body;
|
||||
|
||||
if (!id || !sessionId || !key) {
|
||||
return sendError(res, 'Missing required parameters');
|
||||
}
|
||||
|
||||
const pluginManager = getPluginManager();
|
||||
if (!pluginManager) return sendError(res, 'Plugin Manager not found');
|
||||
|
||||
const plugin = pluginManager.getPluginInfo(id);
|
||||
if (!plugin) return sendError(res, 'Plugin not loaded');
|
||||
|
||||
// 获取会话
|
||||
const session = activeConfigSessions.get(sessionId);
|
||||
if (!session) {
|
||||
return sendError(res, 'Session not found');
|
||||
}
|
||||
|
||||
// 更新会话中的当前配置
|
||||
session.currentConfig = currentConfig || {};
|
||||
|
||||
// 如果插件有响应式处理器,调用它
|
||||
if (plugin.module.plugin_on_config_change) {
|
||||
const uiController = {
|
||||
updateSchema: (schema: any[]) => {
|
||||
session.res.write(`event: schema\n`);
|
||||
session.res.write(`data: ${JSON.stringify({ type: 'full', schema })}\n\n`);
|
||||
},
|
||||
updateField: (fieldKey: string, field: any) => {
|
||||
session.res.write(`event: schema\n`);
|
||||
session.res.write(`data: ${JSON.stringify({ type: 'updateField', key: fieldKey, field })}\n\n`);
|
||||
},
|
||||
removeField: (fieldKey: string) => {
|
||||
session.res.write(`event: schema\n`);
|
||||
session.res.write(`data: ${JSON.stringify({ type: 'removeField', key: fieldKey })}\n\n`);
|
||||
},
|
||||
addField: (field: any, afterKey?: string) => {
|
||||
session.res.write(`event: schema\n`);
|
||||
session.res.write(`data: ${JSON.stringify({ type: 'addField', field, afterKey })}\n\n`);
|
||||
},
|
||||
showField: (fieldKey: string) => {
|
||||
session.res.write(`event: schema\n`);
|
||||
session.res.write(`data: ${JSON.stringify({ type: 'showField', key: fieldKey })}\n\n`);
|
||||
},
|
||||
hideField: (fieldKey: string) => {
|
||||
session.res.write(`event: schema\n`);
|
||||
session.res.write(`data: ${JSON.stringify({ type: 'hideField', key: fieldKey })}\n\n`);
|
||||
},
|
||||
getCurrentConfig: () => session.currentConfig
|
||||
};
|
||||
|
||||
try {
|
||||
await plugin.module.plugin_on_config_change(
|
||||
plugin.context,
|
||||
uiController,
|
||||
key,
|
||||
value,
|
||||
currentConfig || {}
|
||||
);
|
||||
} catch (e: any) {
|
||||
session.res.write(`event: error\n`);
|
||||
session.res.write(`data: ${JSON.stringify({ message: e.message })}\n\n`);
|
||||
}
|
||||
}
|
||||
|
||||
return sendSuccess(res, { message: 'Change processed' });
|
||||
};
|
||||
|
||||
export const SetPluginConfigHandler: RequestHandler = async (req, res) => {
|
||||
@ -228,7 +431,7 @@ export const SetPluginConfigHandler: RequestHandler = async (req, res) => {
|
||||
} catch (e: any) {
|
||||
return sendError(res, 'Error updating config: ' + e.message);
|
||||
}
|
||||
} else if (plugin.module.plugin_config_schema || plugin.module.plugin_config_ui) {
|
||||
} else if (plugin.module.plugin_config_schema || plugin.module.plugin_config_ui || plugin.module.plugin_config_controller) {
|
||||
// Default behavior: write to default config path
|
||||
try {
|
||||
const configPath = plugin.context?.configPath || pluginManager.getPluginConfigPath(id);
|
||||
|
||||
@ -5,7 +5,9 @@ import {
|
||||
UninstallPluginHandler,
|
||||
GetPluginConfigHandler,
|
||||
SetPluginConfigHandler,
|
||||
RegisterPluginManagerHandler
|
||||
RegisterPluginManagerHandler,
|
||||
PluginConfigSSEHandler,
|
||||
PluginConfigChangeHandler
|
||||
} from '@/napcat-webui-backend/src/api/Plugin';
|
||||
import {
|
||||
GetPluginStoreListHandler,
|
||||
@ -21,6 +23,8 @@ router.post('/SetStatus', SetPluginStatusHandler);
|
||||
router.post('/Uninstall', UninstallPluginHandler);
|
||||
router.get('/Config', GetPluginConfigHandler);
|
||||
router.post('/Config', SetPluginConfigHandler);
|
||||
router.get('/Config/SSE', PluginConfigSSEHandler);
|
||||
router.post('/Config/Change', PluginConfigChangeHandler);
|
||||
router.post('/RegisterManager', RegisterPluginManagerHandler);
|
||||
|
||||
// 插件商店相关路由
|
||||
|
||||
@ -37,12 +37,18 @@ export interface PluginConfigSchemaItem {
|
||||
default?: any;
|
||||
options?: { label: string; value: string | number; }[];
|
||||
placeholder?: string;
|
||||
/** 标记此字段为响应式:值变化时触发 schema 刷新 */
|
||||
reactive?: boolean;
|
||||
/** 是否隐藏此字段 */
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
/** 插件配置响应 */
|
||||
export interface PluginConfigResponse {
|
||||
schema: PluginConfigSchemaItem[];
|
||||
config: Record<string, unknown>;
|
||||
/** 是否支持响应式更新 */
|
||||
supportReactive?: boolean;
|
||||
}
|
||||
|
||||
/** 服务端响应 */
|
||||
@ -143,4 +149,41 @@ export default class PluginManager {
|
||||
public static async setPluginConfig (id: string, config: Record<string, unknown>): Promise<void> {
|
||||
await serverRequest.post<ServerResponse<void>>('/Plugin/Config', { id, config });
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知配置字段变化
|
||||
* @param id 插件包名
|
||||
* @param sessionId SSE 会话 ID
|
||||
* @param key 变化的字段
|
||||
* @param value 新值
|
||||
* @param currentConfig 当前配置
|
||||
*/
|
||||
public static async notifyConfigChange (
|
||||
id: string,
|
||||
sessionId: string,
|
||||
key: string,
|
||||
value: unknown,
|
||||
currentConfig: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
await serverRequest.post<ServerResponse<void>>('/Plugin/Config/Change', {
|
||||
id,
|
||||
sessionId,
|
||||
key,
|
||||
value,
|
||||
currentConfig
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取配置 SSE URL
|
||||
* @param id 插件包名
|
||||
* @param config 初始配置
|
||||
*/
|
||||
public static getConfigSSEUrl (id: string, config?: Record<string, unknown>): string {
|
||||
const params = new URLSearchParams({ id });
|
||||
if (config) {
|
||||
params.set('config', JSON.stringify(config));
|
||||
}
|
||||
return `/api/Plugin/Config/SSE?${params.toString()}`;
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,9 +3,11 @@ import { Button } from '@heroui/button';
|
||||
import { Input } from '@heroui/input';
|
||||
import { Select, SelectItem } from '@heroui/select';
|
||||
import { Switch } from '@heroui/switch';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { EventSourcePolyfill } from 'event-source-polyfill';
|
||||
import PluginManager, { PluginConfigSchemaItem } from '@/controllers/plugin_manager';
|
||||
import key from '@/const/key';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
@ -14,26 +16,182 @@ interface Props {
|
||||
pluginId: string;
|
||||
}
|
||||
|
||||
/** Schema 更新事件类型 */
|
||||
interface SchemaUpdateEvent {
|
||||
type: 'full' | 'updateField' | 'removeField' | 'addField' | 'showField' | 'hideField';
|
||||
schema?: PluginConfigSchemaItem[];
|
||||
key?: string;
|
||||
field?: Partial<PluginConfigSchemaItem>;
|
||||
afterKey?: string;
|
||||
}
|
||||
|
||||
export default function PluginConfigModal ({ isOpen, onOpenChange, pluginId }: Props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [schema, setSchema] = useState<PluginConfigSchemaItem[]>([]);
|
||||
const [config, setConfig] = useState<Record<string, unknown>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [supportReactive, setSupportReactive] = useState(false);
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const [connected, setConnected] = useState(false);
|
||||
|
||||
// SSE 连接引用
|
||||
const eventSourceRef = useRef<EventSourcePolyfill | null>(null);
|
||||
// 当前配置引用(用于 SSE 回调)
|
||||
const configRef = useRef<Record<string, unknown>>({});
|
||||
|
||||
// 同步 config 到 ref
|
||||
useEffect(() => {
|
||||
configRef.current = config;
|
||||
}, [config]);
|
||||
|
||||
/** 处理 schema 更新事件 */
|
||||
const handleSchemaUpdate = useCallback((event: SchemaUpdateEvent) => {
|
||||
switch (event.type) {
|
||||
case 'full':
|
||||
if (event.schema) {
|
||||
setSchema(event.schema);
|
||||
}
|
||||
break;
|
||||
case 'updateField':
|
||||
if (event.key && event.field) {
|
||||
setSchema(prev => prev.map(item =>
|
||||
item.key === event.key ? { ...item, ...event.field } : item
|
||||
));
|
||||
}
|
||||
break;
|
||||
case 'removeField':
|
||||
if (event.key) {
|
||||
setSchema(prev => prev.filter(item => item.key !== event.key));
|
||||
}
|
||||
break;
|
||||
case 'addField':
|
||||
if (event.field) {
|
||||
setSchema(prev => {
|
||||
const newField = event.field as PluginConfigSchemaItem;
|
||||
// 检查字段是否已存在,如果存在则更新
|
||||
const existingIndex = prev.findIndex(item => item.key === newField.key);
|
||||
if (existingIndex !== -1) {
|
||||
// 字段已存在,更新它
|
||||
const newSchema = [...prev];
|
||||
newSchema[existingIndex] = { ...newSchema[existingIndex], ...newField };
|
||||
return newSchema;
|
||||
}
|
||||
// 字段不存在,添加新字段
|
||||
if (event.afterKey) {
|
||||
const index = prev.findIndex(item => item.key === event.afterKey);
|
||||
if (index !== -1) {
|
||||
const newSchema = [...prev];
|
||||
newSchema.splice(index + 1, 0, newField);
|
||||
return newSchema;
|
||||
}
|
||||
}
|
||||
return [...prev, newField];
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'showField':
|
||||
if (event.key) {
|
||||
setSchema(prev => prev.map(item =>
|
||||
item.key === event.key ? { ...item, hidden: false } : item
|
||||
));
|
||||
}
|
||||
break;
|
||||
case 'hideField':
|
||||
if (event.key) {
|
||||
setSchema(prev => prev.map(item =>
|
||||
item.key === event.key ? { ...item, hidden: true } : item
|
||||
));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/** 建立 SSE 连接 */
|
||||
const connectSSE = useCallback((initialConfig: Record<string, unknown>) => {
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
}
|
||||
|
||||
const token = localStorage.getItem(key.token);
|
||||
if (!token) {
|
||||
console.warn('未登录,无法建立 SSE 连接');
|
||||
return;
|
||||
}
|
||||
const _token = JSON.parse(token);
|
||||
|
||||
const url = PluginManager.getConfigSSEUrl(pluginId, initialConfig);
|
||||
const es = new EventSourcePolyfill(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${_token}`,
|
||||
Accept: 'text/event-stream',
|
||||
},
|
||||
withCredentials: true,
|
||||
});
|
||||
eventSourceRef.current = es;
|
||||
|
||||
es.addEventListener('connected', (e) => {
|
||||
const data = JSON.parse((e as MessageEvent).data);
|
||||
setSessionId(data.sessionId);
|
||||
setConnected(true);
|
||||
});
|
||||
|
||||
es.addEventListener('schema', (e) => {
|
||||
const data = JSON.parse((e as MessageEvent).data);
|
||||
handleSchemaUpdate(data);
|
||||
});
|
||||
|
||||
es.addEventListener('error', (e) => {
|
||||
try {
|
||||
const data = JSON.parse((e as MessageEvent).data);
|
||||
toast.error('插件错误: ' + data.message);
|
||||
} catch {
|
||||
// SSE 连接错误
|
||||
setConnected(false);
|
||||
}
|
||||
});
|
||||
|
||||
es.onerror = () => {
|
||||
setConnected(false);
|
||||
};
|
||||
}, [pluginId, handleSchemaUpdate]);
|
||||
|
||||
/** 关闭 SSE 连接 */
|
||||
const disconnectSSE = useCallback(() => {
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
setSessionId(null);
|
||||
setConnected(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && pluginId) {
|
||||
loadConfig();
|
||||
}
|
||||
}, [isOpen, pluginId]);
|
||||
return () => {
|
||||
disconnectSSE();
|
||||
};
|
||||
}, [isOpen, pluginId, disconnectSSE]);
|
||||
|
||||
/** 初始加载配置 */
|
||||
const loadConfig = async () => {
|
||||
setLoading(true);
|
||||
setSchema([]);
|
||||
setConfig({});
|
||||
setSupportReactive(false);
|
||||
disconnectSSE();
|
||||
|
||||
try {
|
||||
const data = await PluginManager.getPluginConfig(pluginId);
|
||||
setSchema(data.schema || []);
|
||||
setConfig(data.config || {});
|
||||
setSupportReactive(!!data.supportReactive);
|
||||
|
||||
// 如果支持响应式,建立 SSE 连接
|
||||
if (data.supportReactive) {
|
||||
connectSSE(data.config || {});
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error('加载配置失败: ' + e.message);
|
||||
} finally {
|
||||
@ -54,9 +212,21 @@ export default function PluginConfigModal ({ isOpen, onOpenChange, pluginId }: P
|
||||
}
|
||||
};
|
||||
|
||||
const updateConfig = (key: string, value: any) => {
|
||||
setConfig((prev: any) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
/** 更新配置 */
|
||||
const updateConfig = useCallback((key: string, value: any) => {
|
||||
setConfig((prev) => {
|
||||
const newConfig = { ...prev, [key]: value };
|
||||
|
||||
// 如果是响应式字段且已连接 SSE,通知后端
|
||||
const field = schema.find(item => item.key === key);
|
||||
if (field?.reactive && sessionId && connected) {
|
||||
PluginManager.notifyConfigChange(pluginId, sessionId, key, value, newConfig)
|
||||
.catch(e => console.error('通知配置变化失败:', e));
|
||||
}
|
||||
|
||||
return newConfig;
|
||||
});
|
||||
}, [schema, sessionId, connected, pluginId]);
|
||||
|
||||
const renderField = (item: PluginConfigSchemaItem) => {
|
||||
const value = config[item.key] ?? item.default;
|
||||
@ -100,9 +270,9 @@ export default function PluginConfigModal ({ isOpen, onOpenChange, pluginId }: P
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case 'select':
|
||||
// Handle value matching for default selected keys
|
||||
case 'select': {
|
||||
const selectedValue = value !== undefined ? String(value) : undefined;
|
||||
const options = item.options || [];
|
||||
return (
|
||||
<Select
|
||||
key={item.key}
|
||||
@ -111,22 +281,23 @@ export default function PluginConfigModal ({ isOpen, onOpenChange, pluginId }: P
|
||||
selectedKeys={selectedValue ? [selectedValue] : []}
|
||||
onSelectionChange={(keys) => {
|
||||
const val = Array.from(keys)[0];
|
||||
// Map back to value
|
||||
const opt = item.options?.find(o => String(o.value) === val);
|
||||
const opt = options.find(o => String(o.value) === val);
|
||||
updateConfig(item.key, opt ? opt.value : val);
|
||||
}}
|
||||
description={item.description}
|
||||
className="mb-4"
|
||||
>
|
||||
{(item.options || []).map((opt) => (
|
||||
{options.map((opt) => (
|
||||
<SelectItem key={String(opt.value)} textValue={opt.label}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
case 'multi-select':
|
||||
}
|
||||
case 'multi-select': {
|
||||
const selectedKeys = Array.isArray(value) ? value.map(String) : [];
|
||||
const options = item.options || [];
|
||||
return (
|
||||
<Select
|
||||
key={item.key}
|
||||
@ -136,7 +307,7 @@ export default function PluginConfigModal ({ isOpen, onOpenChange, pluginId }: P
|
||||
selectedKeys={new Set(selectedKeys)}
|
||||
onSelectionChange={(keys) => {
|
||||
const selected = Array.from(keys).map(k => {
|
||||
const opt = item.options?.find(o => String(o.value) === k);
|
||||
const opt = options.find(o => String(o.value) === k);
|
||||
return opt ? opt.value : k;
|
||||
});
|
||||
updateConfig(item.key, selected);
|
||||
@ -144,13 +315,14 @@ export default function PluginConfigModal ({ isOpen, onOpenChange, pluginId }: P
|
||||
description={item.description}
|
||||
className="mb-4"
|
||||
>
|
||||
{(item.options || []).map((opt) => (
|
||||
{options.map((opt) => (
|
||||
<SelectItem key={String(opt.value)} textValue={opt.label}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
case 'html':
|
||||
return (
|
||||
<div key={item.key} className="mb-4">
|
||||
@ -178,7 +350,14 @@ export default function PluginConfigModal ({ isOpen, onOpenChange, pluginId }: P
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader className="flex flex-col gap-1">
|
||||
插件配置: {pluginId}
|
||||
<div className="flex items-center gap-2">
|
||||
插件配置: {pluginId}
|
||||
{supportReactive && (
|
||||
<span className={`text-tiny px-2 py-0.5 rounded ${connected ? 'bg-success-100 text-success-600' : 'bg-warning-100 text-warning-600'}`}>
|
||||
{connected ? '已连接' : '未连接'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
{loading ? (
|
||||
@ -188,7 +367,7 @@ export default function PluginConfigModal ({ isOpen, onOpenChange, pluginId }: P
|
||||
{schema.length === 0 ? (
|
||||
<div className="text-center text-default-500">No configuration schema available.</div>
|
||||
) : (
|
||||
schema.map(renderField)
|
||||
schema.filter(item => !item.hidden).map(renderField)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@ -207,3 +386,4 @@ export default function PluginConfigModal ({ isOpen, onOpenChange, pluginId }: P
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -213,8 +213,8 @@ importers:
|
||||
packages/napcat-plugin-builtin:
|
||||
dependencies:
|
||||
napcat-types:
|
||||
specifier: 0.0.9
|
||||
version: 0.0.9
|
||||
specifier: 0.0.10
|
||||
version: 0.0.10
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^22.0.1
|
||||
@ -2943,15 +2943,9 @@ packages:
|
||||
'@types/event-source-polyfill@1.0.5':
|
||||
resolution: {integrity: sha512-iaiDuDI2aIFft7XkcwMzDWLqo7LVDixd2sR6B4wxJut9xcp/Ev9bO4EFg4rm6S9QxATLBj5OPxdeocgmhjwKaw==}
|
||||
|
||||
'@types/express-serve-static-core@4.19.8':
|
||||
resolution: {integrity: sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==}
|
||||
|
||||
'@types/express-serve-static-core@5.1.0':
|
||||
resolution: {integrity: sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==}
|
||||
|
||||
'@types/express@4.17.25':
|
||||
resolution: {integrity: sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==}
|
||||
|
||||
'@types/express@5.0.5':
|
||||
resolution: {integrity: sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ==}
|
||||
|
||||
@ -2967,9 +2961,6 @@ packages:
|
||||
'@types/http-errors@2.0.5':
|
||||
resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
|
||||
|
||||
'@types/ip@1.1.3':
|
||||
resolution: {integrity: sha512-64waoJgkXFTYnCYDUWgSATJ/dXEBanVkaP5d4Sbk7P6U7cTTMhxVyROTckc6JKdwCrgnAjZMn0k3177aQxtDEA==}
|
||||
|
||||
'@types/js-cookie@3.0.6':
|
||||
resolution: {integrity: sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==}
|
||||
|
||||
@ -3053,17 +3044,9 @@ packages:
|
||||
'@types/use-sync-external-store@0.0.6':
|
||||
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
|
||||
|
||||
'@types/winston@2.4.4':
|
||||
resolution: {integrity: sha512-BVGCztsypW8EYwJ+Hq+QNYiT/MUyCif0ouBH+flrY66O5W+KIXAMML6E/0fJpm7VjIzgangahl5S03bJJQGrZw==}
|
||||
deprecated: This is a stub types definition. winston provides its own type definitions, so you do not need this installed.
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
|
||||
|
||||
'@types/yaml@1.9.7':
|
||||
resolution: {integrity: sha512-8WMXRDD1D+wCohjfslHDgICd2JtMATZU8CkhH8LVJqcJs6dyYj5TGptzP8wApbmEullGBSsCEzzap73DQ1HJaA==}
|
||||
deprecated: This is a stub types definition. yaml provides its own type definitions, so you do not need this installed.
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.46.4':
|
||||
resolution: {integrity: sha512-R48VhmTJqplNyDxCyqqVkFSZIx1qX6PzwqgcXn1olLrzxcSBDlOsbtcnQuQhNtnNiJ4Xe5gREI1foajYaYU2Vg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
@ -5417,8 +5400,8 @@ packages:
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
napcat-types@0.0.9:
|
||||
resolution: {integrity: sha512-lhK9SgGotQc58jobqnZEnDTadwcwT/Y62aGCH4hQijdiP2eSiu3YWX2oFFoUJxyIpUKFYAfWJkPUv1YHZ/aaWQ==}
|
||||
napcat-types@0.0.10:
|
||||
resolution: {integrity: sha512-Y3EIdDm6rDJjdDqorQMbfyTVpyZvRRPa95yPmIl1LCTbmZMkfJw2YLQHvAcItEpTxUEW1Pve1ipNfmHBVmZL8Q==}
|
||||
|
||||
napcat.protobuf@1.1.4:
|
||||
resolution: {integrity: sha512-z7XtLSBJ/PxmYb0VD/w+eYr/X3LyGz+SZ2QejFTOczwt6zWNxy2yV1mTMTvJoc3BWkI3ESVFRxkuT6+pj1tb1Q==}
|
||||
@ -9805,13 +9788,6 @@ snapshots:
|
||||
|
||||
'@types/event-source-polyfill@1.0.5': {}
|
||||
|
||||
'@types/express-serve-static-core@4.19.8':
|
||||
dependencies:
|
||||
'@types/node': 22.19.1
|
||||
'@types/qs': 6.14.0
|
||||
'@types/range-parser': 1.2.7
|
||||
'@types/send': 1.2.1
|
||||
|
||||
'@types/express-serve-static-core@5.1.0':
|
||||
dependencies:
|
||||
'@types/node': 22.19.1
|
||||
@ -9819,13 +9795,6 @@ snapshots:
|
||||
'@types/range-parser': 1.2.7
|
||||
'@types/send': 1.2.1
|
||||
|
||||
'@types/express@4.17.25':
|
||||
dependencies:
|
||||
'@types/body-parser': 1.19.6
|
||||
'@types/express-serve-static-core': 4.19.8
|
||||
'@types/qs': 6.14.0
|
||||
'@types/serve-static': 1.15.10
|
||||
|
||||
'@types/express@5.0.5':
|
||||
dependencies:
|
||||
'@types/body-parser': 1.19.6
|
||||
@ -9845,10 +9814,6 @@ snapshots:
|
||||
|
||||
'@types/http-errors@2.0.5': {}
|
||||
|
||||
'@types/ip@1.1.3':
|
||||
dependencies:
|
||||
'@types/node': 22.19.1
|
||||
|
||||
'@types/js-cookie@3.0.6': {}
|
||||
|
||||
'@types/json-schema@7.0.15': {}
|
||||
@ -9930,18 +9895,10 @@ snapshots:
|
||||
|
||||
'@types/use-sync-external-store@0.0.6': {}
|
||||
|
||||
'@types/winston@2.4.4':
|
||||
dependencies:
|
||||
winston: 3.18.3
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
dependencies:
|
||||
'@types/node': 22.19.1
|
||||
|
||||
'@types/yaml@1.9.7':
|
||||
dependencies:
|
||||
yaml: 2.8.2
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.46.4(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
@ -12788,17 +12745,10 @@ snapshots:
|
||||
|
||||
nanoid@3.3.11: {}
|
||||
|
||||
napcat-types@0.0.9:
|
||||
napcat-types@0.0.10:
|
||||
dependencies:
|
||||
'@sinclair/typebox': 0.34.41
|
||||
'@types/cors': 2.8.19
|
||||
'@types/express': 4.17.25
|
||||
'@types/ip': 1.1.3
|
||||
'@types/multer': 1.4.13
|
||||
'@types/node': 22.19.1
|
||||
'@types/winston': 2.4.4
|
||||
'@types/ws': 8.18.1
|
||||
'@types/yaml': 1.9.7
|
||||
|
||||
napcat.protobuf@1.1.4:
|
||||
dependencies:
|
||||
@ -14591,7 +14541,8 @@ snapshots:
|
||||
|
||||
yallist@4.0.0: {}
|
||||
|
||||
yaml@2.8.2: {}
|
||||
yaml@2.8.2:
|
||||
optional: true
|
||||
|
||||
yargs-parser@20.2.9: {}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user