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 { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/response';
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 = {
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',
},
],
};
// 插件商店源配置
const PLUGIN_STORE_SOURCES = [
'https://raw.githubusercontent.com/NapNeko/napcat-plugin-index/main/plugins.v4.json',
];
// 插件目录
const PLUGINS_DIR = path.join(process.cwd(), 'plugins');
// 确保插件目录存在
if (!fs.existsSync(PLUGINS_DIR)) {
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
}
// 插件列表缓存
let pluginListCache: PluginStoreList | null = null;
let cacheTimestamp: number = 0;
const CACHE_TTL = 10 * 60 * 1000; // 10分钟缓存
/**
* 使
* 10
*/
async function fetchPluginList (): Promise<PluginStoreList> {
// 检查缓存
const now = Date.now();
if (pluginListCache && (now - cacheTimestamp) < CACHE_TTL) {
//console.log('Using cached plugin list');
return pluginListCache;
}
const errors: string[] = [];
for (const source of PLUGIN_STORE_SOURCES) {
// 使用镜像系统的 raw 镜像列表
for (const mirror of GITHUB_RAW_MIRRORS) {
try {
const url = mirror ? `${mirror}/${source.replace('https://raw.githubusercontent.com/', '')}` : source;
const response = await fetch(url, {
headers: {
'User-Agent': 'NapCat-WebUI',
},
signal: AbortSignal.timeout(10000), // 10秒超时
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
//console.log(`Successfully fetched plugin list from: ${url}`);
// 更新缓存
pluginListCache = data as PluginStoreList;
cacheTimestamp = now;
return pluginListCache;
} catch (e: any) {
const errorMsg = `Failed to fetch from ${source} via mirror: ${e.message}`;
console.warn(errorMsg);
errors.push(errorMsg);
}
}
}
throw new Error(`All plugin sources failed:\n${errors.join('\n')}`);
}
/**
* 使
* GitHub Release URL 使
*/
async function downloadFile (url: string, destPath: string): Promise<void> {
try {
let downloadUrl: string;
// 判断是否是 GitHub Release URL
// 格式: https://github.com/{owner}/{repo}/releases/download/{tag}/{filename}
const githubReleasePattern = /^https:\/\/github\.com\/[^/]+\/[^/]+\/releases\/download\//;
if (githubReleasePattern.test(url)) {
// 使用镜像系统查找可用的下载 URL支持 GitHub Release 镜像)
console.log(`Detected GitHub Release URL: ${url}`);
downloadUrl = await findAvailableDownloadUrl(url, {
validateContent: true,
minFileSize: 1024, // 最小 1KB
timeout: 60000, // 60秒超时
useFastMirrors: true, // 使用快速镜像列表
});
} else {
// 其他URL直接下载
console.log(`Direct download URL: ${url}`);
downloadUrl = url;
}
console.log(`Downloading from: ${downloadUrl}`);
const response = await fetch(downloadUrl, {
headers: {
'User-Agent': 'NapCat-WebUI',
},
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) => {
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);
const data = await fetchPluginList();
return sendSuccess(res, data);
} catch (e: any) {
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) => {
try {
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) {
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) => {
try {
@ -156,21 +196,38 @@ export const InstallPluginFromStoreHandler: RequestHandler = async (req, res) =>
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) {
return sendError(res, 'Plugin not found in store');
}
// TODO: 实现实际的下载和安装逻辑
// 1. 下载插件文件
// 2. 解压到插件目录
// 3. 加载插件
// 下载插件
const tempZipPath = path.join(PLUGINS_DIR, `${id}.temp.zip`);
return sendSuccess(res, {
message: 'Plugin installation started',
plugin: plugin
});
try {
await downloadFile(plugin.downloadUrl, tempZipPath);
// 解压插件
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) {
return sendError(res, 'Failed to install plugin: ' + e.message);
}

View File

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

View File

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