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:
手瓜一十雪
2026-01-28 13:56:40 +08:00
parent 40e11b9d29
commit 89bac8f6e3
10 changed files with 482 additions and 44 deletions

View File

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

View File

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