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 58220d3fbc
commit c2d3a8034d
11 changed files with 569 additions and 7 deletions

View File

@ -16,7 +16,8 @@ const getPluginManager = (): OB11PluginMangerAdapter | null => {
export const GetPluginListHandler: RequestHandler = async (_req, res) => {
const pluginManager = getPluginManager();
if (!pluginManager) {
return sendError(res, 'Plugin Manager not found');
// 返回成功但带特殊标记
return sendSuccess(res, { plugins: [], pluginManagerNotFound: true });
}
// 辅助函数:根据文件名/路径生成唯一ID作为配置键
@ -113,7 +114,7 @@ export const GetPluginListHandler: RequestHandler = async (_req, res) => {
}
}
return sendSuccess(res, allPlugins);
return sendSuccess(res, { plugins: allPlugins, pluginManagerNotFound: false });
};
export const ReloadPluginHandler: RequestHandler = async (req, res) => {
@ -124,7 +125,7 @@ export const ReloadPluginHandler: RequestHandler = async (req, res) => {
const pluginManager = getPluginManager();
if (!pluginManager) {
return sendError(res, 'Plugin Manager not found');
return sendError(res, '插件管理器未加载,请检查 plugins 目录是否存在');
}
const success = await pluginManager.reloadPlugin(name);

View File

@ -0,0 +1,177 @@
import { RequestHandler } from 'express';
import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/response';
import { PluginStoreList } from '@/napcat-webui-backend/src/types/PluginStore';
// Mock数据 - 模拟远程插件列表
const mockPluginStoreData: PluginStoreList = {
version: '1.0.0',
updateTime: new Date().toISOString(),
plugins: [
{
id: 'napcat-plugin-example',
name: '示例插件',
version: '1.0.0',
description: '这是一个示例插件展示如何开发NapCat插件',
author: 'NapCat Team',
homepage: 'https://github.com/NapNeko/NapCatQQ',
repository: 'https://github.com/NapNeko/NapCatQQ',
downloadUrl: 'https://example.com/plugins/napcat-plugin-example-1.0.0.zip',
tags: ['示例', '教程'],
screenshots: ['https://picsum.photos/800/600?random=1'],
minNapCatVersion: '1.0.0',
downloads: 1234,
rating: 4.5,
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-20T00:00:00Z',
},
{
id: 'napcat-plugin-auto-reply',
name: '自动回复插件',
version: '2.1.0',
description: '支持关键词匹配的自动回复功能,可配置多种回复规则',
author: 'Community',
homepage: 'https://github.com/example/auto-reply',
repository: 'https://github.com/example/auto-reply',
downloadUrl: 'https://example.com/plugins/napcat-plugin-auto-reply-2.1.0.zip',
tags: ['自动回复', '消息处理'],
minNapCatVersion: '1.0.0',
downloads: 5678,
rating: 4.8,
createdAt: '2024-01-05T00:00:00Z',
updatedAt: '2024-01-22T00:00:00Z',
},
{
id: 'napcat-plugin-welcome',
name: '入群欢迎插件',
version: '1.2.3',
description: '新成员入群时自动发送欢迎消息,支持自定义欢迎语',
author: 'Developer',
homepage: 'https://github.com/example/welcome',
repository: 'https://github.com/example/welcome',
downloadUrl: 'https://example.com/plugins/napcat-plugin-welcome-1.2.3.zip',
tags: ['欢迎', '群管理'],
minNapCatVersion: '1.0.0',
downloads: 3456,
rating: 4.3,
createdAt: '2024-01-10T00:00:00Z',
updatedAt: '2024-01-18T00:00:00Z',
},
{
id: 'napcat-plugin-music',
name: '音乐点歌插件',
version: '3.0.1',
description: '支持网易云、QQ音乐等平台的点歌功能',
author: 'Music Lover',
homepage: 'https://github.com/example/music',
repository: 'https://github.com/example/music',
downloadUrl: 'https://example.com/plugins/napcat-plugin-music-3.0.1.zip',
tags: ['音乐', '娱乐'],
screenshots: ['https://picsum.photos/800/600?random=4', 'https://picsum.photos/800/600?random=5'],
minNapCatVersion: '1.1.0',
downloads: 8901,
rating: 4.9,
createdAt: '2023-12-01T00:00:00Z',
updatedAt: '2024-01-23T00:00:00Z',
},
{
id: 'napcat-plugin-admin',
name: '群管理插件',
version: '2.5.0',
description: '提供踢人、禁言、设置管理员等群管理功能',
author: 'Admin Tools',
homepage: 'https://github.com/example/admin',
repository: 'https://github.com/example/admin',
downloadUrl: 'https://example.com/plugins/napcat-plugin-admin-2.5.0.zip',
tags: ['管理', '群管理', '工具'],
minNapCatVersion: '1.0.0',
downloads: 6789,
rating: 4.6,
createdAt: '2023-12-15T00:00:00Z',
updatedAt: '2024-01-21T00:00:00Z',
},
{
id: 'napcat-plugin-image-search',
name: '以图搜图插件',
version: '1.5.2',
description: '支持多个搜图引擎,快速找到图片来源',
author: 'Image Hunter',
homepage: 'https://github.com/example/image-search',
repository: 'https://github.com/example/image-search',
downloadUrl: 'https://example.com/plugins/napcat-plugin-image-search-1.5.2.zip',
tags: ['图片', '搜索', '工具'],
minNapCatVersion: '1.0.0',
downloads: 4567,
rating: 4.4,
createdAt: '2024-01-08T00:00:00Z',
updatedAt: '2024-01-19T00:00:00Z',
},
],
};
/**
*
* URL读取
*/
export const GetPluginStoreListHandler: RequestHandler = async (_req, res) => {
try {
// TODO: 未来从远程URL读取
// const remoteUrl = 'https://napcat.example.com/plugin-list.json';
// const response = await fetch(remoteUrl);
// const data = await response.json();
// 目前返回Mock数据
return sendSuccess(res, mockPluginStoreData);
} catch (e: any) {
return sendError(res, 'Failed to fetch plugin store list: ' + e.message);
}
};
/**
*
*/
export const GetPluginStoreDetailHandler: RequestHandler = async (req, res) => {
try {
const { id } = req.params;
const plugin = mockPluginStoreData.plugins.find(p => p.id === id);
if (!plugin) {
return sendError(res, 'Plugin not found');
}
return sendSuccess(res, plugin);
} catch (e: any) {
return sendError(res, 'Failed to fetch plugin detail: ' + e.message);
}
};
/**
*
* TODO: 实现实际的下载和安装逻辑
*/
export const InstallPluginFromStoreHandler: RequestHandler = async (req, res) => {
try {
const { id } = req.body;
if (!id) {
return sendError(res, 'Plugin ID is required');
}
const plugin = mockPluginStoreData.plugins.find(p => p.id === id);
if (!plugin) {
return sendError(res, 'Plugin not found in store');
}
// TODO: 实现实际的下载和安装逻辑
// 1. 下载插件文件
// 2. 解压到插件目录
// 3. 加载插件
return sendSuccess(res, {
message: 'Plugin installation started',
plugin: plugin
});
} catch (e: any) {
return sendError(res, 'Failed to install plugin: ' + e.message);
}
};

View File

@ -1,5 +1,6 @@
import { Router } from 'express';
import { GetPluginListHandler, ReloadPluginHandler, SetPluginStatusHandler, UninstallPluginHandler } from '@/napcat-webui-backend/src/api/Plugin';
import { GetPluginStoreListHandler, GetPluginStoreDetailHandler, InstallPluginFromStoreHandler } from '@/napcat-webui-backend/src/api/PluginStore';
const router: Router = Router();
@ -8,4 +9,9 @@ router.post('/Reload', ReloadPluginHandler);
router.post('/SetStatus', SetPluginStatusHandler);
router.post('/Uninstall', UninstallPluginHandler);
// 插件商店相关路由
router.get('/Store/List', GetPluginStoreListHandler);
router.get('/Store/Detail/:id', GetPluginStoreDetailHandler);
router.post('/Store/Install', InstallPluginFromStoreHandler);
export { router as PluginRouter };

View File

@ -0,0 +1,27 @@
// 插件商店相关类型定义
export interface PluginStoreItem {
id: string; // 插件唯一标识
name: string; // 插件名称
version: string; // 最新版本
description: string; // 插件描述
author: string; // 作者
homepage?: string; // 主页链接
repository?: string; // 仓库地址
downloadUrl: string; // 下载地址
tags?: string[]; // 标签
icon?: string; // 图标URL
screenshots?: string[]; // 截图
minNapCatVersion?: string; // 最低NapCat版本要求
dependencies?: Record<string, string>; // 依赖
downloads?: number; // 下载次数
rating?: number; // 评分
createdAt?: string; // 创建时间
updatedAt?: string; // 更新时间
}
export interface PluginStoreList {
version: string; // 索引版本
updateTime: string; // 更新时间
plugins: PluginStoreItem[]; // 插件列表
}

View File

@ -26,6 +26,7 @@ const LogsPage = lazy(() => import('@/pages/dashboard/logs'));
const NetworkPage = lazy(() => import('@/pages/dashboard/network'));
const TerminalPage = lazy(() => import('@/pages/dashboard/terminal'));
const PluginPage = lazy(() => import('@/pages/dashboard/plugin'));
const PluginStorePage = lazy(() => import('@/pages/dashboard/plugin_store'));
function App () {
return (
@ -78,6 +79,7 @@ function AppRoutes () {
<Route path='file_manager' element={<FileManagerPage />} />
<Route path='terminal' element={<TerminalPage />} />
<Route path='plugins' element={<PluginPage />} />
<Route path='plugin_store' element={<PluginStorePage />} />
<Route path='about' element={<AboutPage />} />
</Route>
<Route path='/qq_login' element={<QQLoginPage />} />

View File

@ -0,0 +1,117 @@
import { Button } from '@heroui/button';
import { Chip } from '@heroui/chip';
import { useState } from 'react';
import { IoMdStar, IoMdDownload } from 'react-icons/io';
import DisplayCardContainer from './container';
import { PluginStoreItem } from '@/types/plugin-store';
export interface PluginStoreCardProps {
data: PluginStoreItem;
onInstall: () => Promise<void>;
}
const PluginStoreCard: React.FC<PluginStoreCardProps> = ({
data,
onInstall,
}) => {
const { name, version, author, description, tags, rating, icon } = data;
const [processing, setProcessing] = useState(false);
const handleInstall = () => {
setProcessing(true);
onInstall().finally(() => setProcessing(false));
};
return (
<DisplayCardContainer
className='w-full max-w-[420px]'
title={name}
tag={
<Chip
className="ml-auto"
color="primary"
size="sm"
variant="flat"
>
v{version}
</Chip>
}
enableSwitch={
icon ? (
<div className="flex items-center gap-2">
<img
src={icon}
alt={name}
className="w-10 h-10 rounded-lg object-cover"
/>
</div>
) : undefined
}
action={
<Button
fullWidth
radius='full'
size='sm'
color='primary'
startContent={<IoMdDownload size={16} />}
onPress={handleInstall}
isLoading={processing}
isDisabled={processing}
>
</Button>
}
>
<div className='grid grid-cols-2 gap-3'>
<div className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors'>
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>
</span>
<div className='text-sm font-medium text-default-700 dark:text-white/90 truncate'>
{author || '未知'}
</div>
</div>
<div className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors'>
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>
</span>
<div className='text-sm font-medium text-default-700 dark:text-white/90 truncate'>
v{version}
</div>
</div>
<div className='col-span-2 flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors'>
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>
</span>
<div className='text-sm font-medium text-default-700 dark:text-white/90 break-words line-clamp-2 h-10 overflow-hidden'>
{description || '暂无描述'}
</div>
</div>
{rating && (
<div className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors'>
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>
</span>
<div className='flex items-center gap-1 text-sm font-medium text-default-700 dark:text-white/90'>
<IoMdStar className='text-warning' size={16} />
<span>{rating.toFixed(1)}</span>
</div>
</div>
)}
{tags && tags.length > 0 && (
<div className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors'>
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>
</span>
<div className='text-sm font-medium text-default-700 dark:text-white/90 truncate'>
{tags.slice(0, 2).join(' · ')}
</div>
</div>
)}
</div>
</DisplayCardContainer>
);
};
export default PluginStoreCard;

View File

@ -9,6 +9,7 @@ import {
LuTerminal,
LuZap,
LuPackage,
LuStore,
} from 'react-icons/lu';
export type SiteConfig = typeof siteConfig;
@ -65,6 +66,11 @@ export const siteConfig = {
icon: <LuPackage className='w-5 h-5' />,
href: '/plugins',
},
{
label: '插件商店',
icon: <LuStore className='w-5 h-5' />,
href: '/plugin_store',
},
{
label: '系统终端',
icon: <LuTerminal className='w-5 h-5' />,

View File

@ -1,4 +1,5 @@
import { serverRequest } from '@/utils/request';
import { PluginStoreList, PluginStoreItem } from '@/types/plugin-store';
export interface PluginItem {
name: string;
@ -9,6 +10,11 @@ export interface PluginItem {
filename?: string;
}
export interface PluginListResponse {
plugins: PluginItem[];
pluginManagerNotFound: boolean;
}
export interface ServerResponse<T> {
code: number;
message: string;
@ -17,7 +23,7 @@ export interface ServerResponse<T> {
export default class PluginManager {
public static async getPluginList () {
const { data } = await serverRequest.get<ServerResponse<PluginItem[]>>('/Plugin/List');
const { data } = await serverRequest.get<ServerResponse<PluginListResponse>>('/Plugin/List');
return data.data;
}
@ -32,4 +38,19 @@ export default class PluginManager {
public static async uninstallPlugin (name: string, filename?: string) {
await serverRequest.post<ServerResponse<void>>('/Plugin/Uninstall', { name, filename });
}
// 插件商店相关方法
public static async getPluginStoreList () {
const { data } = await serverRequest.get<ServerResponse<PluginStoreList>>('/Plugin/Store/List');
return data.data;
}
public static async getPluginStoreDetail (id: string) {
const { data } = await serverRequest.get<ServerResponse<PluginStoreItem>>(`/Plugin/Store/Detail/${id}`);
return data.data;
}
public static async installPluginFromStore (id: string) {
await serverRequest.post<ServerResponse<void>>('/Plugin/Store/Install', { id });
}
}

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

View File

@ -0,0 +1,27 @@
// 插件商店相关类型定义
export interface PluginStoreItem {
id: string; // 插件唯一标识
name: string; // 插件名称
version: string; // 最新版本
description: string; // 插件描述
author: string; // 作者
homepage?: string; // 主页链接
repository?: string; // 仓库地址
downloadUrl: string; // 下载地址
tags?: string[]; // 标签
icon?: string; // 图标URL
screenshots?: string[]; // 截图
minNapCatVersion?: string; // 最低NapCat版本要求
dependencies?: Record<string, string>; // 依赖
downloads?: number; // 下载次数
rating?: number; // 评分
createdAt?: string; // 创建时间
updatedAt?: string; // 更新时间
}
export interface PluginStoreList {
version: string; // 索引版本
updateTime: string; // 更新时间
plugins: PluginStoreItem[]; // 插件列表
}