Add plugin store feature to backend and frontend

Implemented plugin store API endpoints and types in the backend, including mock data and handlers for listing, detail, and install actions. Added plugin store page, card component, and related logic to the frontend, with navigation and categorized browsing. Updated plugin manager controller and site config to support the new plugin store functionality.
This commit is contained in:
手瓜一十雪
2026-01-24 12:00:26 +08:00
parent e239662514
commit dc858807ae
11 changed files with 569 additions and 7 deletions

View File

@@ -11,13 +11,20 @@ import useDialog from '@/hooks/use-dialog';
export default function PluginPage () {
const [plugins, setPlugins] = useState<PluginItem[]>([]);
const [loading, setLoading] = useState(false);
const [pluginManagerNotFound, setPluginManagerNotFound] = useState(false);
const dialog = useDialog();
const loadPlugins = async () => {
setLoading(true);
setPluginManagerNotFound(false);
try {
const data = await PluginManager.getPluginList();
setPlugins(data);
const result = await PluginManager.getPluginList();
if (result.pluginManagerNotFound) {
setPluginManagerNotFound(true);
setPlugins([]);
} else {
setPlugins(result.plugins);
}
} catch (e: any) {
toast.error(e.message);
} finally {
@@ -94,7 +101,17 @@ export default function PluginPage () {
</Button>
</div>
{plugins.length === 0 ? (
{pluginManagerNotFound ? (
<div className="flex flex-col items-center justify-center min-h-[400px] text-center">
<div className="text-6xl mb-4">📦</div>
<h2 className="text-xl font-semibold text-default-700 dark:text-white/90 mb-2">
</h2>
<p className="text-default-500 dark:text-white/60 max-w-md">
plugins
</p>
</div>
) : plugins.length === 0 ? (
<div className="text-default-400"></div>
) : (
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 justify-start items-stretch gap-x-2 gap-y-4'>

View File

@@ -0,0 +1,161 @@
import { Button } from '@heroui/button';
import { Input } from '@heroui/input';
import { Tab, Tabs } from '@heroui/tabs';
import { useEffect, useMemo, useState } from 'react';
import toast from 'react-hot-toast';
import { IoMdRefresh, IoMdSearch } from 'react-icons/io';
import clsx from 'clsx';
import PageLoading from '@/components/page_loading';
import PluginStoreCard from '@/components/display_card/plugin_store_card';
import PluginManager from '@/controllers/plugin_manager';
import { PluginStoreItem } from '@/types/plugin-store';
interface EmptySectionProps {
isEmpty: boolean;
}
const EmptySection: React.FC<EmptySectionProps> = ({ isEmpty }) => {
return (
<div
className={clsx('text-default-400', {
hidden: !isEmpty,
})}
>
</div>
);
};
export default function PluginStorePage () {
const [plugins, setPlugins] = useState<PluginStoreItem[]>([]);
const [loading, setLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [activeTab, setActiveTab] = useState<string>('all');
const loadPlugins = async () => {
setLoading(true);
try {
const data = await PluginManager.getPluginStoreList();
setPlugins(data.plugins);
} catch (e: any) {
toast.error(e.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadPlugins();
}, []);
// 按标签分类和搜索
const categorizedPlugins = useMemo(() => {
let filtered = plugins;
// 搜索过滤
if (searchQuery) {
filtered = filtered.filter(
(p) =>
p.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
p.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
p.author.toLowerCase().includes(searchQuery.toLowerCase()) ||
p.tags?.some((tag) => tag.toLowerCase().includes(searchQuery.toLowerCase()))
);
}
// 按下载量排序
filtered.sort((a, b) => (b.downloads || 0) - (a.downloads || 0));
// 定义主要分类
const categories: Record<string, PluginStoreItem[]> = {
all: filtered,
popular: filtered.filter(p => (p.downloads || 0) > 5000),
tools: filtered.filter(p => p.tags?.some(t => ['工具', '管理', '群管理'].includes(t))),
entertainment: filtered.filter(p => p.tags?.some(t => ['娱乐', '音乐', '游戏'].includes(t))),
message: filtered.filter(p => p.tags?.some(t => ['消息处理', '自动回复', '欢迎'].includes(t))),
};
return categories;
}, [plugins, searchQuery]);
const tabs = useMemo(() => {
return [
{ key: 'all', title: '全部', count: categorizedPlugins.all?.length || 0 },
{ key: 'popular', title: '热门推荐', count: categorizedPlugins.popular?.length || 0 },
{ key: 'tools', title: '工具管理', count: categorizedPlugins.tools?.length || 0 },
{ key: 'entertainment', title: '娱乐功能', count: categorizedPlugins.entertainment?.length || 0 },
{ key: 'message', title: '消息处理', count: categorizedPlugins.message?.length || 0 },
];
}, [categorizedPlugins]);
const handleInstall = async () => {
toast('该功能尚未完工,敬请期待', {
icon: '🚧',
duration: 3000,
});
};
return (
<>
<title> - NapCat WebUI</title>
<div className="p-2 md:p-4 relative">
<PageLoading loading={loading} />
{/* 头部 */}
<div className="flex mb-6 items-center gap-4">
<h1 className="text-2xl font-bold"></h1>
<Button
isIconOnly
className="bg-default-100/50 hover:bg-default-200/50 text-default-700 backdrop-blur-md"
radius="full"
onPress={loadPlugins}
>
<IoMdRefresh size={24} />
</Button>
</div>
{/* 搜索框 */}
<div className="mb-6">
<Input
placeholder="搜索插件名称、描述、作者或标签..."
startContent={<IoMdSearch className="text-default-400" />}
value={searchQuery}
onValueChange={setSearchQuery}
className="max-w-md"
/>
</div>
{/* 标签页 */}
<Tabs
aria-label="Plugin Store Categories"
className="max-w-full"
selectedKey={activeTab}
onSelectionChange={(key) => setActiveTab(String(key))}
classNames={{
tabList: 'bg-white/40 dark:bg-black/20 backdrop-blur-md',
cursor: 'bg-white/80 dark:bg-white/10 backdrop-blur-md shadow-sm',
}}
>
{tabs.map((tab) => (
<Tab
key={tab.key}
title={`${tab.title} (${tab.count})`}
>
<EmptySection isEmpty={!categorizedPlugins[tab.key]?.length} />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 justify-start items-stretch gap-x-2 gap-y-4">
{categorizedPlugins[tab.key]?.map((plugin) => (
<PluginStoreCard
key={plugin.id}
data={plugin}
onInstall={handleInstall}
/>
))}
</div>
</Tab>
))}
</Tabs>
</div>
</>
);
}