Implement real plugin store backend and install logic

Replaces mock plugin store data with live fetching from a remote source using mirrors and caching. Implements actual plugin download, extraction, and installation logic in the backend. Updates frontend to call the new install API and display installation results, and removes unused icon/rating display from plugin cards.
This commit is contained in:
手瓜一十雪 2026-01-27 22:03:47 +08:00
parent 8b676ed693
commit 24623f18d8
3 changed files with 197 additions and 148 deletions

View File

@ -1,126 +1,166 @@
import { RequestHandler } from 'express'; import { RequestHandler } from 'express';
import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/response'; import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/response';
import { PluginStoreList } from '@/napcat-webui-backend/src/types/PluginStore'; import { PluginStoreList } from '@/napcat-webui-backend/src/types/PluginStore';
import * as fs from 'fs';
import * as path from 'path';
import { pipeline } from 'stream/promises';
import { createWriteStream } from 'fs';
import compressing from 'compressing';
import { findAvailableDownloadUrl, GITHUB_RAW_MIRRORS } from 'napcat-common/src/mirror';
// Mock数据 - 模拟远程插件列表 // 插件商店源配置
const mockPluginStoreData: PluginStoreList = { const PLUGIN_STORE_SOURCES = [
version: '1.0.0', 'https://raw.githubusercontent.com/NapNeko/napcat-plugin-index/main/plugins.v4.json',
updateTime: new Date().toISOString(), ];
plugins: [
{ // 插件目录
id: 'napcat-plugin-example', const PLUGINS_DIR = path.join(process.cwd(), 'plugins');
name: '示例插件',
version: '1.0.0', // 确保插件目录存在
description: '这是一个示例插件展示如何开发NapCat插件', if (!fs.existsSync(PLUGINS_DIR)) {
author: 'NapCat Team', fs.mkdirSync(PLUGINS_DIR, { recursive: true });
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: ['示例', '教程'], let pluginListCache: PluginStoreList | null = null;
screenshots: ['https://picsum.photos/800/600?random=1'], let cacheTimestamp: number = 0;
minNapCatVersion: '1.0.0', const CACHE_TTL = 10 * 60 * 1000; // 10分钟缓存
downloads: 1234,
rating: 4.5, /**
createdAt: '2024-01-01T00:00:00Z', * 使
updatedAt: '2024-01-20T00:00:00Z', * 10
}, */
{ async function fetchPluginList (): Promise<PluginStoreList> {
id: 'napcat-plugin-auto-reply', // 检查缓存
name: '自动回复插件', const now = Date.now();
version: '2.1.0', if (pluginListCache && (now - cacheTimestamp) < CACHE_TTL) {
description: '支持关键词匹配的自动回复功能,可配置多种回复规则', //console.log('Using cached plugin list');
author: 'Community', return pluginListCache;
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', const errors: string[] = [];
tags: ['自动回复', '消息处理'],
minNapCatVersion: '1.0.0', for (const source of PLUGIN_STORE_SOURCES) {
downloads: 5678, // 使用镜像系统的 raw 镜像列表
rating: 4.8, for (const mirror of GITHUB_RAW_MIRRORS) {
createdAt: '2024-01-05T00:00:00Z', try {
updatedAt: '2024-01-22T00:00:00Z', const url = mirror ? `${mirror}/${source.replace('https://raw.githubusercontent.com/', '')}` : source;
},
{ const response = await fetch(url, {
id: 'napcat-plugin-welcome', headers: {
name: '入群欢迎插件', 'User-Agent': 'NapCat-WebUI',
version: '1.2.3', },
description: '新成员入群时自动发送欢迎消息,支持自定义欢迎语', signal: AbortSignal.timeout(10000), // 10秒超时
author: 'Developer', });
homepage: 'https://github.com/example/welcome',
repository: 'https://github.com/example/welcome', if (!response.ok) {
downloadUrl: 'https://example.com/plugins/napcat-plugin-welcome-1.2.3.zip', throw new Error(`HTTP ${response.status}: ${response.statusText}`);
tags: ['欢迎', '群管理'], }
minNapCatVersion: '1.0.0',
downloads: 3456, const data = await response.json();
rating: 4.3, //console.log(`Successfully fetched plugin list from: ${url}`);
createdAt: '2024-01-10T00:00:00Z',
updatedAt: '2024-01-18T00:00:00Z', // 更新缓存
}, pluginListCache = data as PluginStoreList;
{ cacheTimestamp = now;
id: 'napcat-plugin-music',
name: '音乐点歌插件', return pluginListCache;
version: '3.0.1', } catch (e: any) {
description: '支持网易云、QQ音乐等平台的点歌功能', const errorMsg = `Failed to fetch from ${source} via mirror: ${e.message}`;
author: 'Music Lover', console.warn(errorMsg);
homepage: 'https://github.com/example/music', errors.push(errorMsg);
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', throw new Error(`All plugin sources failed:\n${errors.join('\n')}`);
downloads: 8901, }
rating: 4.9,
createdAt: '2023-12-01T00:00:00Z', /**
updatedAt: '2024-01-23T00:00:00Z', * 使
}, * GitHub Release URL 使
{ */
id: 'napcat-plugin-admin', async function downloadFile (url: string, destPath: string): Promise<void> {
name: '群管理插件', try {
version: '2.5.0', let downloadUrl: string;
description: '提供踢人、禁言、设置管理员等群管理功能',
author: 'Admin Tools', // 判断是否是 GitHub Release URL
homepage: 'https://github.com/example/admin', // 格式: https://github.com/{owner}/{repo}/releases/download/{tag}/{filename}
repository: 'https://github.com/example/admin', const githubReleasePattern = /^https:\/\/github\.com\/[^/]+\/[^/]+\/releases\/download\//;
downloadUrl: 'https://example.com/plugins/napcat-plugin-admin-2.5.0.zip',
tags: ['管理', '群管理', '工具'], if (githubReleasePattern.test(url)) {
minNapCatVersion: '1.0.0', // 使用镜像系统查找可用的下载 URL支持 GitHub Release 镜像)
downloads: 6789, console.log(`Detected GitHub Release URL: ${url}`);
rating: 4.6, downloadUrl = await findAvailableDownloadUrl(url, {
createdAt: '2023-12-15T00:00:00Z', validateContent: true,
updatedAt: '2024-01-21T00:00:00Z', minFileSize: 1024, // 最小 1KB
}, timeout: 60000, // 60秒超时
{ useFastMirrors: true, // 使用快速镜像列表
id: 'napcat-plugin-image-search', });
name: '以图搜图插件', } else {
version: '1.5.2', // 其他URL直接下载
description: '支持多个搜图引擎,快速找到图片来源', console.log(`Direct download URL: ${url}`);
author: 'Image Hunter', downloadUrl = url;
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', console.log(`Downloading from: ${downloadUrl}`);
tags: ['图片', '搜索', '工具'],
minNapCatVersion: '1.0.0', const response = await fetch(downloadUrl, {
downloads: 4567, headers: {
rating: 4.4, 'User-Agent': 'NapCat-WebUI',
createdAt: '2024-01-08T00:00:00Z', },
updatedAt: '2024-01-19T00:00:00Z', signal: AbortSignal.timeout(60000),
}, });
],
}; if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
if (!response.body) {
throw new Error('Response body is null');
}
// 写入文件
const fileStream = createWriteStream(destPath);
await pipeline(response.body as any, fileStream);
console.log(`Successfully downloaded to: ${destPath}`);
} catch (e: any) {
// 删除可能的不完整文件
if (fs.existsSync(destPath)) {
fs.unlinkSync(destPath);
}
throw new Error(`Download failed: ${e.message}`);
}
}
/**
*
*/
async function extractPlugin (zipPath: string, pluginId: string): Promise<void> {
const pluginDir = path.join(PLUGINS_DIR, pluginId);
// 如果目录已存在,先删除
if (fs.existsSync(pluginDir)) {
fs.rmSync(pluginDir, { recursive: true, force: true });
}
// 创建插件目录
fs.mkdirSync(pluginDir, { recursive: true });
// 解压
await compressing.zip.uncompress(zipPath, pluginDir);
//console.log(`Plugin extracted to: ${pluginDir}`);
}
/** /**
* *
* URL读取
*/ */
export const GetPluginStoreListHandler: RequestHandler = async (_req, res) => { export const GetPluginStoreListHandler: RequestHandler = async (_req, res) => {
try { try {
// TODO: 未来从远程URL读取 const data = await fetchPluginList();
// const remoteUrl = 'https://napcat.example.com/plugin-list.json'; return sendSuccess(res, data);
// const response = await fetch(remoteUrl);
// const data = await response.json();
// 目前返回Mock数据
return sendSuccess(res, mockPluginStoreData);
} catch (e: any) { } catch (e: any) {
return sendError(res, 'Failed to fetch plugin store list: ' + e.message); return sendError(res, 'Failed to fetch plugin store list: ' + e.message);
} }
@ -132,7 +172,8 @@ export const GetPluginStoreListHandler: RequestHandler = async (_req, res) => {
export const GetPluginStoreDetailHandler: RequestHandler = async (req, res) => { export const GetPluginStoreDetailHandler: RequestHandler = async (req, res) => {
try { try {
const { id } = req.params; const { id } = req.params;
const plugin = mockPluginStoreData.plugins.find(p => p.id === id); const data = await fetchPluginList();
const plugin = data.plugins.find(p => p.id === id);
if (!plugin) { if (!plugin) {
return sendError(res, 'Plugin not found'); return sendError(res, 'Plugin not found');
@ -146,7 +187,6 @@ export const GetPluginStoreDetailHandler: RequestHandler = async (req, res) => {
/** /**
* *
* TODO: 实现实际的下载和安装逻辑
*/ */
export const InstallPluginFromStoreHandler: RequestHandler = async (req, res) => { export const InstallPluginFromStoreHandler: RequestHandler = async (req, res) => {
try { try {
@ -156,21 +196,38 @@ export const InstallPluginFromStoreHandler: RequestHandler = async (req, res) =>
return sendError(res, 'Plugin ID is required'); return sendError(res, 'Plugin ID is required');
} }
const plugin = mockPluginStoreData.plugins.find(p => p.id === id); // 获取插件信息
const data = await fetchPluginList();
const plugin = data.plugins.find(p => p.id === id);
if (!plugin) { if (!plugin) {
return sendError(res, 'Plugin not found in store'); return sendError(res, 'Plugin not found in store');
} }
// TODO: 实现实际的下载和安装逻辑 // 下载插件
// 1. 下载插件文件 const tempZipPath = path.join(PLUGINS_DIR, `${id}.temp.zip`);
// 2. 解压到插件目录
// 3. 加载插件
return sendSuccess(res, { try {
message: 'Plugin installation started', await downloadFile(plugin.downloadUrl, tempZipPath);
plugin: plugin
}); // 解压插件
await extractPlugin(tempZipPath, id);
// 删除临时文件
fs.unlinkSync(tempZipPath);
return sendSuccess(res, {
message: 'Plugin installed successfully',
plugin: plugin,
installPath: path.join(PLUGINS_DIR, id),
});
} catch (downloadError: any) {
// 清理临时文件
if (fs.existsSync(tempZipPath)) {
fs.unlinkSync(tempZipPath);
}
throw downloadError;
}
} catch (e: any) { } catch (e: any) {
return sendError(res, 'Failed to install plugin: ' + e.message); return sendError(res, 'Failed to install plugin: ' + e.message);
} }

View File

@ -1,7 +1,7 @@
import { Button } from '@heroui/button'; import { Button } from '@heroui/button';
import { Chip } from '@heroui/chip'; import { Chip } from '@heroui/chip';
import { useState } from 'react'; import { useState } from 'react';
import { IoMdStar, IoMdDownload } from 'react-icons/io'; import { IoMdDownload } from 'react-icons/io';
import DisplayCardContainer from './container'; import DisplayCardContainer from './container';
import { PluginStoreItem } from '@/types/plugin-store'; import { PluginStoreItem } from '@/types/plugin-store';
@ -15,7 +15,7 @@ const PluginStoreCard: React.FC<PluginStoreCardProps> = ({
data, data,
onInstall, onInstall,
}) => { }) => {
const { name, version, author, description, tags, rating, icon } = data; const { name, version, author, description, tags, id } = data;
const [processing, setProcessing] = useState(false); const [processing, setProcessing] = useState(false);
const handleInstall = () => { const handleInstall = () => {
@ -37,17 +37,7 @@ const PluginStoreCard: React.FC<PluginStoreCardProps> = ({
v{version} v{version}
</Chip> </Chip>
} }
enableSwitch={ enableSwitch={undefined}
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={ action={
<Button <Button
fullWidth fullWidth
@ -88,14 +78,13 @@ const PluginStoreCard: React.FC<PluginStoreCardProps> = ({
{description || '暂无描述'} {description || '暂无描述'}
</div> </div>
</div> </div>
{rating && ( {id && (
<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'> <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 className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>
</span> </span>
<div className='flex items-center gap-1 text-sm font-medium text-default-700 dark:text-white/90'> <div className='text-sm font-medium text-default-700 dark:text-white/90 break-words line-clamp-2 h-10 overflow-hidden'>
<IoMdStar className='text-warning' size={16} /> {id || '包名'}
<span>{rating.toFixed(1)}</span>
</div> </div>
</div> </div>
)} )}

View File

@ -86,11 +86,14 @@ export default function PluginStorePage () {
]; ];
}, [categorizedPlugins]); }, [categorizedPlugins]);
const handleInstall = async () => { const handleInstall = async (pluginId: string) => {
toast('该功能尚未完工,敬请期待', { try {
icon: '🚧', await PluginManager.installPluginFromStore(pluginId);
duration: 3000, toast.success('插件安装成功!');
}); // 可以选择刷新插件列表或导航到插件管理页面
} catch (error: any) {
toast.error(`安装失败: ${error.message || '未知错误'}`);
}
}; };
return ( return (
@ -145,7 +148,7 @@ export default function PluginStorePage () {
<PluginStoreCard <PluginStoreCard
key={plugin.id} key={plugin.id}
data={plugin} data={plugin}
onInstall={handleInstall} onInstall={() => handleInstall(plugin.id)}
/> />
))} ))}
</div> </div>