mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-03-01 16:20:25 +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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user