Add plugin WebUI extension page and API routing support

Introduces a plugin router registry for registering plugin-specific API routes, static resources, and extension pages. Updates the plugin manager and context to expose the router, and implements backend and frontend support for serving and displaying plugin extension pages in the WebUI. Also adds a demo extension page and static resource to the builtin plugin.
This commit is contained in:
手瓜一十雪
2026-01-30 12:48:24 +08:00
parent bb69ae6c62
commit 012c283dee
18 changed files with 1245 additions and 23 deletions

View File

@@ -0,0 +1,162 @@
import { Tab, Tabs } from '@heroui/tabs';
import { Button } from '@heroui/button';
import { Spinner } from '@heroui/spinner';
import { useEffect, useState, useMemo } from 'react';
import toast from 'react-hot-toast';
import { IoMdRefresh } from 'react-icons/io';
import { MdExtension } from 'react-icons/md';
import PageLoading from '@/components/page_loading';
import pluginManager from '@/controllers/plugin_manager';
interface ExtensionPage {
pluginId: string;
pluginName: string;
path: string;
title: string;
icon?: string;
description?: string;
}
export default function ExtensionPage () {
const [loading, setLoading] = useState(true);
const [extensionPages, setExtensionPages] = useState<ExtensionPage[]>([]);
const [selectedTab, setSelectedTab] = useState<string>('');
const [iframeLoading, setIframeLoading] = useState(false);
const fetchExtensionPages = async () => {
setLoading(true);
try {
const result = await pluginManager.getPluginList();
if (result.pluginManagerNotFound) {
setExtensionPages([]);
} else {
setExtensionPages(result.extensionPages || []);
// 默认选中第一个
if (result.extensionPages?.length > 0 && !selectedTab) {
setSelectedTab(`${result.extensionPages[0].pluginId}:${result.extensionPages[0].path}`);
}
}
} catch (error) {
const msg = (error as Error).message;
toast.error(`获取扩展页面失败: ${msg}`);
} finally {
setLoading(false);
}
};
const refresh = async () => {
await fetchExtensionPages();
};
// 生成 tabs
const tabs = useMemo(() => {
return extensionPages.map(page => ({
key: `${page.pluginId}:${page.path}`,
title: page.title,
pluginId: page.pluginId,
pluginName: page.pluginName,
path: page.path,
icon: page.icon,
description: page.description
}));
}, [extensionPages]);
// 获取当前选中页面的 iframe URL
const currentPageUrl = useMemo(() => {
if (!selectedTab) return '';
const [pluginId, ...pathParts] = selectedTab.split(':');
const path = pathParts.join(':').replace(/^\//, '');
// 获取认证 token
const token = localStorage.getItem('token') || '';
return `/api/Plugin/page/${pluginId}/${path}?webui_token=${encodeURIComponent(token)}`;
}, [selectedTab]);
useEffect(() => {
fetchExtensionPages();
}, []);
useEffect(() => {
if (currentPageUrl) {
setIframeLoading(true);
}
}, [currentPageUrl]);
const handleIframeLoad = () => {
setIframeLoading(false);
};
return (
<>
<title> - NapCat WebUI</title>
<div className="p-2 md:p-4 relative h-full flex flex-col">
<PageLoading loading={loading} />
<div className="flex mb-4 items-center gap-4">
<div className="flex items-center gap-2 text-default-600">
<MdExtension size={24} />
<span className="text-lg font-medium"></span>
</div>
<Button
isIconOnly
className="bg-default-100/50 hover:bg-default-200/50 text-default-700 backdrop-blur-md"
radius="full"
onPress={refresh}
>
<IoMdRefresh size={24} />
</Button>
</div>
{extensionPages.length === 0 && !loading ? (
<div className="flex-1 flex flex-col items-center justify-center text-default-400">
<MdExtension size={64} className="mb-4 opacity-50" />
<p className="text-lg"></p>
<p className="text-sm mt-2"> WebUI </p>
</div>
) : (
<div className="flex-1 flex flex-col min-h-0">
<Tabs
aria-label="Extension Pages"
className="max-w-full"
selectedKey={selectedTab}
onSelectionChange={(key) => setSelectedTab(key as string)}
classNames={{
tabList: 'bg-white/40 dark:bg-black/20 backdrop-blur-md flex-wrap',
cursor: 'bg-white/80 dark:bg-white/10 backdrop-blur-md shadow-sm',
panel: 'flex-1 min-h-0 p-0'
}}
>
{tabs.map((tab) => (
<Tab
key={tab.key}
title={
<div className="flex items-center gap-2">
{tab.icon && <span>{tab.icon}</span>}
<span>{tab.title}</span>
<span className="text-xs text-default-400">({tab.pluginName})</span>
</div>
}
>
<div className="relative w-full h-[calc(100vh-220px)] bg-white/40 dark:bg-black/20 backdrop-blur-md rounded-lg overflow-hidden">
{iframeLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-default-100/50 z-10">
<Spinner size="lg" />
</div>
)}
<iframe
src={currentPageUrl}
className="w-full h-full border-0"
onLoad={handleIframeLoad}
title={tab.title}
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
/>
</div>
</Tab>
))}
</Tabs>
</div>
)}
</div>
</>
);
}