mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-02-04 14:41:14 +00:00
Add plugin config management to backend and frontend
Introduces a unified plugin configuration schema and API in the backend, with endpoints for getting and setting plugin config. Updates the frontend to support plugin config modals, including a UI for editing plugin settings. Also adds support for uninstalling plugins with optional data cleanup, and updates dependencies to use napcat-types@0.0.5.
This commit is contained in:
parent
c5f1792009
commit
d711cdecaf
@ -15,6 +15,45 @@ export interface PluginPackageJson {
|
||||
author?: string;
|
||||
}
|
||||
|
||||
export interface PluginConfigItem {
|
||||
key: string;
|
||||
type: 'string' | 'number' | 'boolean' | 'select' | 'multi-select' | 'html' | 'text';
|
||||
label: string;
|
||||
description?: string;
|
||||
default?: any;
|
||||
options?: { label: string; value: string | number; }[];
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export class NapCatConfig {
|
||||
static text (key: string, label: string, defaultValue?: string, description?: string): PluginConfigItem {
|
||||
return { key, type: 'string', label, default: defaultValue, description };
|
||||
}
|
||||
static number (key: string, label: string, defaultValue?: number, description?: string): PluginConfigItem {
|
||||
return { key, type: 'number', label, default: defaultValue, description };
|
||||
}
|
||||
static boolean (key: string, label: string, defaultValue?: boolean, description?: string): PluginConfigItem {
|
||||
return { key, type: 'boolean', label, default: defaultValue, description };
|
||||
}
|
||||
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 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 html (content: string): PluginConfigItem {
|
||||
return { key: `_html_${Math.random().toString(36).slice(2)}`, type: 'html', label: '', default: content };
|
||||
}
|
||||
static plainText (content: string): PluginConfigItem {
|
||||
return { key: `_text_${Math.random().toString(36).slice(2)}`, type: 'text', label: '', default: content };
|
||||
}
|
||||
static combine (...items: PluginConfigItem[]): PluginConfigSchema {
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
export type PluginConfigSchema = PluginConfigItem[];
|
||||
|
||||
export interface PluginModule<T extends OB11EmitEventContent = OB11EmitEventContent> {
|
||||
plugin_init: (
|
||||
core: NapCatCore,
|
||||
@ -44,6 +83,10 @@ export interface PluginModule<T extends OB11EmitEventContent = OB11EmitEventCont
|
||||
actions: ActionMap,
|
||||
instance: OB11PluginMangerAdapter
|
||||
) => void | Promise<void>;
|
||||
plugin_config_schema?: PluginConfigSchema;
|
||||
plugin_config_ui?: PluginConfigSchema;
|
||||
plugin_get_config?: () => any | Promise<any>;
|
||||
plugin_set_config?: (config: any) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface LoadedPlugin {
|
||||
@ -613,4 +656,11 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
public getPluginDataPath (pluginName: string): string {
|
||||
return path.join(this.pluginPath, pluginName, 'data');
|
||||
}
|
||||
|
||||
public getPluginConfigPath (pluginName: string): string {
|
||||
return path.join(this.getPluginDataPath(pluginName), 'config.json');
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
"description": "NapCat 内置插件",
|
||||
"author": "NapNeko",
|
||||
"dependencies": {
|
||||
"napcat-types": "0.0.3"
|
||||
"napcat-types": "0.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.1"
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "napcat-types",
|
||||
"version": "0.0.4",
|
||||
"version": "0.0.5",
|
||||
"private": false,
|
||||
"type": "module",
|
||||
"types": "./napcat-types/index.d.ts",
|
||||
|
||||
@ -47,7 +47,8 @@ export const GetPluginListHandler: RequestHandler = async (_req, res) => {
|
||||
author: p.packageJson?.author || '',
|
||||
status: 'active',
|
||||
filename: fsName, // 真实文件/目录名
|
||||
loadedName: p.name // 运行时注册的名称,用于重载/卸载
|
||||
loadedName: p.name, // 运行时注册的名称,用于重载/卸载
|
||||
hasConfig: !!(p.module.plugin_config_schema || p.module.plugin_config_ui)
|
||||
});
|
||||
}
|
||||
|
||||
@ -186,7 +187,7 @@ export const SetPluginStatusHandler: RequestHandler = async (req, res) => {
|
||||
};
|
||||
|
||||
export const UninstallPluginHandler: RequestHandler = async (req, res) => {
|
||||
const { name, filename } = req.body;
|
||||
const { name, filename, cleanData } = req.body;
|
||||
// If it's loaded, we use name. If it's disabled, we might use filename.
|
||||
|
||||
const pluginManager = getPluginManager();
|
||||
@ -219,8 +220,82 @@ export const UninstallPluginHandler: RequestHandler = async (req, res) => {
|
||||
if (fs.existsSync(fsPath)) {
|
||||
fs.rmSync(fsPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
if (cleanData && name) {
|
||||
const dataPath = pluginManager.getPluginDataPath(name);
|
||||
if (fs.existsSync(dataPath)) {
|
||||
fs.rmSync(dataPath, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
return sendSuccess(res, { message: 'Uninstalled successfully' });
|
||||
} catch (e: any) {
|
||||
return sendError(res, 'Failed to uninstall: ' + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
export const GetPluginConfigHandler: RequestHandler = async (req, res) => {
|
||||
const name = req.query['name'] as string;
|
||||
if (!name) return sendError(res, 'Plugin Name is required');
|
||||
|
||||
const pluginManager = getPluginManager();
|
||||
if (!pluginManager) return sendError(res, 'Plugin Manager not found');
|
||||
|
||||
const plugin = pluginManager.getPluginInfo(name);
|
||||
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();
|
||||
} catch (e) { }
|
||||
} else if (schema) {
|
||||
// Default behavior: read from default config path
|
||||
try {
|
||||
const configPath = pluginManager.getPluginConfigPath(name);
|
||||
if (fs.existsSync(configPath)) {
|
||||
config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
}
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
return sendSuccess(res, { schema, config });
|
||||
};
|
||||
|
||||
export const SetPluginConfigHandler: RequestHandler = async (req, res) => {
|
||||
const { name, config } = req.body;
|
||||
if (!name || !config) return sendError(res, 'Name and Config required');
|
||||
|
||||
const pluginManager = getPluginManager();
|
||||
if (!pluginManager) return sendError(res, 'Plugin Manager not found');
|
||||
|
||||
const plugin = pluginManager.getPluginInfo(name);
|
||||
if (!plugin) return sendError(res, 'Plugin not loaded');
|
||||
|
||||
if (plugin.module.plugin_set_config) {
|
||||
try {
|
||||
await plugin.module.plugin_set_config(config);
|
||||
return sendSuccess(res, { message: 'Config updated' });
|
||||
} catch (e: any) {
|
||||
return sendError(res, 'Error updating config: ' + e.message);
|
||||
}
|
||||
} else if (plugin.module.plugin_config_schema || plugin.module.plugin_config_ui) {
|
||||
// Default behavior: write to default config path
|
||||
try {
|
||||
const configPath = pluginManager.getPluginConfigPath(name);
|
||||
const configDir = path.dirname(configPath);
|
||||
if (!fs.existsSync(configDir)) {
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
||||
return sendSuccess(res, { message: 'Config saved' });
|
||||
} catch (e: any) {
|
||||
return sendError(res, 'Error saving config: ' + e.message);
|
||||
}
|
||||
} else {
|
||||
return sendError(res, 'Plugin does not support config update');
|
||||
}
|
||||
};
|
||||
|
||||
@ -1,6 +1,18 @@
|
||||
import { Router } from 'express';
|
||||
import { GetPluginListHandler, ReloadPluginHandler, SetPluginStatusHandler, UninstallPluginHandler } from '@/napcat-webui-backend/src/api/Plugin';
|
||||
import { GetPluginStoreListHandler, GetPluginStoreDetailHandler, InstallPluginFromStoreHandler, InstallPluginFromStoreSSEHandler } from '@/napcat-webui-backend/src/api/PluginStore';
|
||||
import {
|
||||
GetPluginListHandler,
|
||||
ReloadPluginHandler,
|
||||
SetPluginStatusHandler,
|
||||
UninstallPluginHandler,
|
||||
GetPluginConfigHandler,
|
||||
SetPluginConfigHandler
|
||||
} from '@/napcat-webui-backend/src/api/Plugin';
|
||||
import {
|
||||
GetPluginStoreListHandler,
|
||||
GetPluginStoreDetailHandler,
|
||||
InstallPluginFromStoreHandler,
|
||||
InstallPluginFromStoreSSEHandler
|
||||
} from '@/napcat-webui-backend/src/api/PluginStore';
|
||||
|
||||
const router: Router = Router();
|
||||
|
||||
@ -8,6 +20,8 @@ router.get('/List', GetPluginListHandler);
|
||||
router.post('/Reload', ReloadPluginHandler);
|
||||
router.post('/SetStatus', SetPluginStatusHandler);
|
||||
router.post('/Uninstall', UninstallPluginHandler);
|
||||
router.get('/Config', GetPluginConfigHandler);
|
||||
router.post('/Config', SetPluginConfigHandler);
|
||||
|
||||
// 插件商店相关路由
|
||||
router.get('/Store/List', GetPluginStoreListHandler);
|
||||
|
||||
@ -3,7 +3,7 @@ import { Switch } from '@heroui/switch';
|
||||
import { Chip } from '@heroui/chip';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { MdDeleteForever, MdPublishedWithChanges } from 'react-icons/md';
|
||||
import { MdDeleteForever, MdPublishedWithChanges, MdSettings } from 'react-icons/md';
|
||||
|
||||
import DisplayCardContainer from './container';
|
||||
import { PluginItem } from '@/controllers/plugin_manager';
|
||||
@ -13,6 +13,8 @@ export interface PluginDisplayCardProps {
|
||||
onReload: () => Promise<void>;
|
||||
onToggleStatus: () => Promise<void>;
|
||||
onUninstall: () => Promise<void>;
|
||||
onConfig?: () => void;
|
||||
hasConfig?: boolean;
|
||||
}
|
||||
|
||||
const PluginDisplayCard: React.FC<PluginDisplayCardProps> = ({
|
||||
@ -20,6 +22,8 @@ const PluginDisplayCard: React.FC<PluginDisplayCardProps> = ({
|
||||
onReload,
|
||||
onToggleStatus,
|
||||
onUninstall,
|
||||
onConfig,
|
||||
hasConfig = false,
|
||||
}) => {
|
||||
const { name, version, author, description, status } = data;
|
||||
const isEnabled = status !== 'disabled';
|
||||
@ -44,32 +48,47 @@ const PluginDisplayCard: React.FC<PluginDisplayCardProps> = ({
|
||||
<DisplayCardContainer
|
||||
className='w-full max-w-[420px]'
|
||||
action={
|
||||
<div className='flex gap-2 w-full'>
|
||||
<Button
|
||||
fullWidth
|
||||
radius='full'
|
||||
size='sm'
|
||||
variant='flat'
|
||||
className='flex-1 bg-default-100 dark:bg-default-50 text-default-600 font-medium hover:bg-primary/20 hover:text-primary transition-colors'
|
||||
startContent={<MdPublishedWithChanges size={16} />}
|
||||
onPress={handleReload}
|
||||
isDisabled={!isEnabled || processing}
|
||||
>
|
||||
重载
|
||||
</Button>
|
||||
<div className='flex flex-col gap-2 w-full'>
|
||||
<div className='flex gap-2 w-full'>
|
||||
<Button
|
||||
fullWidth
|
||||
radius='full'
|
||||
size='sm'
|
||||
variant='flat'
|
||||
className='flex-1 bg-default-100 dark:bg-default-50 text-default-600 font-medium hover:bg-primary/20 hover:text-primary transition-colors'
|
||||
startContent={<MdPublishedWithChanges size={16} />}
|
||||
onPress={handleReload}
|
||||
isDisabled={!isEnabled || processing}
|
||||
>
|
||||
重载
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
radius='full'
|
||||
size='sm'
|
||||
variant='flat'
|
||||
className='flex-1 bg-default-100 dark:bg-default-50 text-default-600 font-medium hover:bg-danger/20 hover:text-danger transition-colors'
|
||||
startContent={<MdDeleteForever size={16} />}
|
||||
onPress={handleUninstall}
|
||||
isDisabled={processing}
|
||||
>
|
||||
卸载
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
radius='full'
|
||||
size='sm'
|
||||
variant='flat'
|
||||
className='flex-1 bg-default-100 dark:bg-default-50 text-default-600 font-medium hover:bg-danger/20 hover:text-danger transition-colors'
|
||||
startContent={<MdDeleteForever size={16} />}
|
||||
onPress={handleUninstall}
|
||||
isDisabled={processing}
|
||||
>
|
||||
卸载
|
||||
</Button>
|
||||
</div>
|
||||
{hasConfig && (
|
||||
<Button
|
||||
fullWidth
|
||||
radius='full'
|
||||
size='sm'
|
||||
variant='flat'
|
||||
className='bg-default-100 dark:bg-default-50 text-default-600 font-medium hover:bg-secondary/20 hover:text-secondary transition-colors'
|
||||
startContent={<MdSettings size={16} />}
|
||||
onPress={onConfig}
|
||||
>
|
||||
配置
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
enableSwitch={
|
||||
|
||||
@ -8,6 +8,7 @@ export interface PluginItem {
|
||||
author: string;
|
||||
status: 'active' | 'disabled' | 'stopped';
|
||||
filename?: string;
|
||||
hasConfig?: boolean;
|
||||
}
|
||||
|
||||
export interface PluginListResponse {
|
||||
@ -15,6 +16,21 @@ export interface PluginListResponse {
|
||||
pluginManagerNotFound: boolean;
|
||||
}
|
||||
|
||||
export interface PluginConfigSchemaItem {
|
||||
key: string;
|
||||
type: 'string' | 'number' | 'boolean' | 'select' | 'multi-select' | 'html' | 'text';
|
||||
label: string;
|
||||
description?: string;
|
||||
default?: any;
|
||||
options?: { label: string; value: string | number; }[];
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export interface PluginConfigResponse {
|
||||
schema: PluginConfigSchemaItem[];
|
||||
config: any;
|
||||
}
|
||||
|
||||
export interface ServerResponse<T> {
|
||||
code: number;
|
||||
message: string;
|
||||
@ -35,8 +51,8 @@ export default class PluginManager {
|
||||
await serverRequest.post<ServerResponse<void>>('/Plugin/SetStatus', { name, enable, filename });
|
||||
}
|
||||
|
||||
public static async uninstallPlugin (name: string, filename?: string) {
|
||||
await serverRequest.post<ServerResponse<void>>('/Plugin/Uninstall', { name, filename });
|
||||
public static async uninstallPlugin (name: string, filename?: string, cleanData?: boolean) {
|
||||
await serverRequest.post<ServerResponse<void>>('/Plugin/Uninstall', { name, filename, cleanData });
|
||||
}
|
||||
|
||||
// 插件商店相关方法
|
||||
@ -56,4 +72,14 @@ export default class PluginManager {
|
||||
timeout: 300000, // 5分钟
|
||||
});
|
||||
}
|
||||
|
||||
// 插件配置相关方法
|
||||
public static async getPluginConfig (name: string) {
|
||||
const { data } = await serverRequest.get<ServerResponse<PluginConfigResponse>>('/Plugin/Config', { params: { name } });
|
||||
return data.data;
|
||||
}
|
||||
|
||||
public static async setPluginConfig (name: string, config: any) {
|
||||
await serverRequest.post<ServerResponse<void>>('/Plugin/Config', { name, config });
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,11 +2,13 @@ import { Button } from '@heroui/button';
|
||||
import { useEffect, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { IoMdRefresh } from 'react-icons/io';
|
||||
import { useDisclosure } from '@heroui/modal';
|
||||
|
||||
import PageLoading from '@/components/page_loading';
|
||||
import PluginDisplayCard from '@/components/display_card/plugin_card';
|
||||
import PluginManager, { PluginItem } from '@/controllers/plugin_manager';
|
||||
import useDialog from '@/hooks/use-dialog';
|
||||
import PluginConfigModal from '@/pages/dashboard/plugin_config_modal';
|
||||
|
||||
export default function PluginPage () {
|
||||
const [plugins, setPlugins] = useState<PluginItem[]>([]);
|
||||
@ -14,16 +16,20 @@ export default function PluginPage () {
|
||||
const [pluginManagerNotFound, setPluginManagerNotFound] = useState(false);
|
||||
const dialog = useDialog();
|
||||
|
||||
const { isOpen, onOpen, onOpenChange } = useDisclosure();
|
||||
const [currentPluginName, setCurrentPluginName] = useState<string>('');
|
||||
|
||||
const loadPlugins = async () => {
|
||||
setLoading(true);
|
||||
setPluginManagerNotFound(false);
|
||||
try {
|
||||
const result = await PluginManager.getPluginList();
|
||||
if (result.pluginManagerNotFound) {
|
||||
const listResult = await PluginManager.getPluginList();
|
||||
|
||||
if (listResult.pluginManagerNotFound) {
|
||||
setPluginManagerNotFound(true);
|
||||
setPlugins([]);
|
||||
} else {
|
||||
setPlugins(result.plugins);
|
||||
setPlugins(listResult.plugins);
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error(e.message);
|
||||
@ -64,11 +70,31 @@ export default function PluginPage () {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
dialog.confirm({
|
||||
title: '卸载插件',
|
||||
content: `确定要卸载插件「${plugin.name}」吗? 此操作不可恢复。`,
|
||||
content: (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p>确定要卸载插件「{plugin.name}」吗? 此操作不可恢复。</p>
|
||||
<p className="text-small text-default-500">如果插件创建了数据文件,是否一并删除?</p>
|
||||
</div>
|
||||
),
|
||||
// This 'dialog' utility might not support returning a value from UI interacting.
|
||||
// We might need to implement a custom confirmation flow if we want a checkbox.
|
||||
// Alternatively, use two buttons? "Uninstall & Clean", "Uninstall Only"?
|
||||
// Standard dialog usually has Confirm/Cancel.
|
||||
// Let's stick to a simpler "Uninstall" and then maybe a second prompt? Or just clean data?
|
||||
// User requested: "Uninstall prompts whether to clean data".
|
||||
// Let's use `window.confirm` for the second step or assume `dialog.confirm` is flexible enough?
|
||||
// I will implement a two-step confirmation or try to modify the dialog hook if visible (not visible here).
|
||||
// Let's use a standard `window.confirm` for the data cleanup question if the custom dialog doesn't support complex return.
|
||||
// Better: Inside onConfirm, ask again?
|
||||
onConfirm: async () => {
|
||||
// Ask for data cleanup
|
||||
// Since we are in an async callback, we can use another dialog or confirm.
|
||||
// Native confirm is ugly but works reliably for logic:
|
||||
const cleanData = window.confirm(`是否同时清理插件「${plugin.name}」的数据文件?\n点击“确定”清理数据,点击“取消”仅卸载插件。`);
|
||||
|
||||
const loadingToast = toast.loading('卸载中...');
|
||||
try {
|
||||
await PluginManager.uninstallPlugin(plugin.name, plugin.filename);
|
||||
await PluginManager.uninstallPlugin(plugin.name, plugin.filename, cleanData);
|
||||
toast.success('卸载成功', { id: loadingToast });
|
||||
loadPlugins();
|
||||
resolve();
|
||||
@ -84,11 +110,22 @@ export default function PluginPage () {
|
||||
});
|
||||
};
|
||||
|
||||
const handleConfig = (plugin: PluginItem) => {
|
||||
setCurrentPluginName(plugin.name); // Use Loaded Name for config lookup
|
||||
onOpen();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>插件管理 - NapCat WebUI</title>
|
||||
<div className='p-2 md:p-4 relative'>
|
||||
<PageLoading loading={loading} />
|
||||
<PluginConfigModal
|
||||
isOpen={isOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
pluginName={currentPluginName}
|
||||
/>
|
||||
|
||||
<div className='flex mb-6 items-center gap-4'>
|
||||
<h1 className="text-2xl font-bold">插件管理</h1>
|
||||
<Button
|
||||
@ -122,6 +159,14 @@ export default function PluginPage () {
|
||||
onReload={() => handleReload(plugin.name)}
|
||||
onToggleStatus={() => handleToggle(plugin)}
|
||||
onUninstall={() => handleUninstall(plugin)}
|
||||
onConfig={() => {
|
||||
if (plugin.hasConfig) {
|
||||
handleConfig(plugin);
|
||||
} else {
|
||||
toast.error('此插件没有配置哦');
|
||||
}
|
||||
}}
|
||||
hasConfig={true}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -0,0 +1,208 @@
|
||||
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from '@heroui/modal';
|
||||
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 toast from 'react-hot-toast';
|
||||
import PluginManager, { PluginConfigSchemaItem } from '@/controllers/plugin_manager';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onOpenChange: () => void;
|
||||
pluginName: string;
|
||||
}
|
||||
|
||||
export default function PluginConfigModal ({ isOpen, onOpenChange, pluginName }: Props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [schema, setSchema] = useState<PluginConfigSchemaItem[]>([]);
|
||||
const [config, setConfig] = useState<any>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && pluginName) {
|
||||
loadConfig();
|
||||
}
|
||||
}, [isOpen, pluginName]);
|
||||
|
||||
const loadConfig = async () => {
|
||||
setLoading(true);
|
||||
setSchema([]);
|
||||
setConfig({});
|
||||
try {
|
||||
const data = await PluginManager.getPluginConfig(pluginName);
|
||||
setSchema(data.schema || []);
|
||||
setConfig(data.config || {});
|
||||
} catch (e: any) {
|
||||
toast.error('Load config failed: ' + e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await PluginManager.setPluginConfig(pluginName, config);
|
||||
toast.success('Configuration saved');
|
||||
onOpenChange();
|
||||
} catch (e: any) {
|
||||
toast.error('Save failed: ' + e.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateConfig = (key: string, value: any) => {
|
||||
setConfig((prev: any) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const renderField = (item: PluginConfigSchemaItem) => {
|
||||
const value = config[item.key] ?? item.default;
|
||||
|
||||
switch (item.type) {
|
||||
case 'string':
|
||||
return (
|
||||
<Input
|
||||
key={item.key}
|
||||
label={item.label}
|
||||
placeholder={item.placeholder || item.description}
|
||||
value={value || ''}
|
||||
onValueChange={(val) => updateConfig(item.key, val)}
|
||||
description={item.description}
|
||||
className="mb-4"
|
||||
/>
|
||||
);
|
||||
case 'number':
|
||||
return (
|
||||
<Input
|
||||
key={item.key}
|
||||
type="number"
|
||||
label={item.label}
|
||||
placeholder={item.placeholder || item.description}
|
||||
value={String(value ?? 0)}
|
||||
onValueChange={(val) => updateConfig(item.key, Number(val))}
|
||||
description={item.description}
|
||||
className="mb-4"
|
||||
/>
|
||||
);
|
||||
case 'boolean':
|
||||
return (
|
||||
<div key={item.key} className="flex justify-between items-center mb-4 p-2 bg-default-100 rounded-lg">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-small">{item.label}</span>
|
||||
{item.description && <span className="text-tiny text-default-500">{item.description}</span>}
|
||||
</div>
|
||||
<Switch
|
||||
isSelected={!!value}
|
||||
onValueChange={(val) => updateConfig(item.key, val)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case 'select':
|
||||
// Handle value matching for default selected keys
|
||||
const selectedValue = value !== undefined ? String(value) : undefined;
|
||||
return (
|
||||
<Select
|
||||
key={item.key}
|
||||
label={item.label}
|
||||
placeholder={item.placeholder || 'Select an option'}
|
||||
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);
|
||||
updateConfig(item.key, opt ? opt.value : val);
|
||||
}}
|
||||
description={item.description}
|
||||
className="mb-4"
|
||||
>
|
||||
{(item.options || []).map((opt) => (
|
||||
<SelectItem key={String(opt.value)} textValue={opt.label}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
case 'multi-select':
|
||||
const selectedKeys = Array.isArray(value) ? value.map(String) : [];
|
||||
return (
|
||||
<Select
|
||||
key={item.key}
|
||||
label={item.label}
|
||||
placeholder={item.placeholder || 'Select options'}
|
||||
selectionMode="multiple"
|
||||
selectedKeys={new Set(selectedKeys)}
|
||||
onSelectionChange={(keys) => {
|
||||
const selected = Array.from(keys).map(k => {
|
||||
const opt = item.options?.find(o => String(o.value) === k);
|
||||
return opt ? opt.value : k;
|
||||
});
|
||||
updateConfig(item.key, selected);
|
||||
}}
|
||||
description={item.description}
|
||||
className="mb-4"
|
||||
>
|
||||
{(item.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">
|
||||
{item.label && <h4 className="text-small font-bold mb-1">{item.label}</h4>}
|
||||
<div dangerouslySetInnerHTML={{ __html: item.default || '' }} className="prose dark:prose-invert max-w-none" />
|
||||
{item.description && <p className="text-tiny text-default-500 mt-1">{item.description}</p>}
|
||||
</div>
|
||||
);
|
||||
case 'text':
|
||||
return (
|
||||
<div key={item.key} className="mb-4">
|
||||
{item.label && <h4 className="text-small font-bold mb-1">{item.label}</h4>}
|
||||
<div className="whitespace-pre-wrap text-default-700">{item.default || ''}</div>
|
||||
{item.description && <p className="text-tiny text-default-500 mt-1">{item.description}</p>}
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange} size="2xl" scrollBehavior="inside">
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader className="flex flex-col gap-1">
|
||||
Configuration: {pluginName}
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
{loading ? (
|
||||
<div className="flex justify-center p-8">Loading configuration...</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{schema.length === 0 ? (
|
||||
<div className="text-center text-default-500">No configuration schema available.</div>
|
||||
) : (
|
||||
schema.map(renderField)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="danger" variant="light" onPress={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button color="primary" onPress={handleSave} isLoading={saving}>
|
||||
Save
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@ -223,8 +223,8 @@ importers:
|
||||
packages/napcat-plugin-builtin:
|
||||
dependencies:
|
||||
napcat-types:
|
||||
specifier: 0.0.3
|
||||
version: 0.0.3
|
||||
specifier: 0.0.5
|
||||
version: 0.0.5
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^22.0.1
|
||||
@ -5448,8 +5448,8 @@ packages:
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
napcat-types@0.0.3:
|
||||
resolution: {integrity: sha512-YZVBvtIw7N2TRck+JcVAoZJRqcoKf9PbKhHggZ/EcQzTkqGLgu8iIgMfQnCYscgXRglYBPexpb78piaEwlVcjQ==}
|
||||
napcat-types@0.0.5:
|
||||
resolution: {integrity: sha512-ihbIdCAsqx4wdiSaKfGgsbC5nn3rjk/xbPO1lqr+3GzU+w/oeWsP0XzdF44Z3Uz6ODOijsoR1VR15HXuP8ukcQ==}
|
||||
|
||||
napcat.protobuf@1.1.4:
|
||||
resolution: {integrity: sha512-z7XtLSBJ/PxmYb0VD/w+eYr/X3LyGz+SZ2QejFTOczwt6zWNxy2yV1mTMTvJoc3BWkI3ESVFRxkuT6+pj1tb1Q==}
|
||||
@ -12819,8 +12819,9 @@ snapshots:
|
||||
|
||||
nanoid@3.3.11: {}
|
||||
|
||||
napcat-types@0.0.3:
|
||||
napcat-types@0.0.5:
|
||||
dependencies:
|
||||
'@sinclair/typebox': 0.34.41
|
||||
'@types/cors': 2.8.19
|
||||
'@types/express': 4.17.25
|
||||
'@types/ip': 1.1.3
|
||||
|
||||
Loading…
Reference in New Issue
Block a user