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:
手瓜一十雪
2026-01-29 20:18:34 +08:00
parent 9f62570fc2
commit a4a93c520f
8 changed files with 590 additions and 94 deletions

View File

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