mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-02-04 06:31:13 +00:00
Add plugin install SSE API and mirror selection UI
Introduces a new SSE-based plugin installation API for real-time progress updates and adds frontend support for selecting download mirrors, especially for GitHub-based plugins. Refactors backend plugin directory handling, improves logging, and updates the frontend to use the new API with user-selectable mirrors and progress feedback.
This commit is contained in:
parent
24623f18d8
commit
6b8cc6756d
@ -383,17 +383,14 @@ export async function testUrlHead (url: string, timeout: number = 5000): Promise
|
||||
}, (res) => {
|
||||
const statusCode = res.statusCode || 0;
|
||||
const contentType = (res.headers['content-type'] as string) || '';
|
||||
const contentLength = parseInt((res.headers['content-length'] as string) || '0', 10);
|
||||
|
||||
// 验证条件:
|
||||
// 简化验证条件:
|
||||
// 1. 状态码 2xx 或 3xx
|
||||
// 2. Content-Type 不应该是 text/html(表示错误页面)
|
||||
// 3. 对于 .zip 文件,Content-Length 应该 > 1MB(避免获取到错误页面)
|
||||
const isValidStatus = statusCode >= 200 && statusCode < 400;
|
||||
const isNotHtmlError = !contentType.includes('text/html');
|
||||
const isValidSize = url.endsWith('.zip') ? contentLength > 1024 * 1024 : true;
|
||||
|
||||
resolve(isValidStatus && isNotHtmlError && isValidSize);
|
||||
resolve(isValidStatus && isNotHtmlError);
|
||||
});
|
||||
|
||||
req.on('error', () => resolve(false));
|
||||
@ -437,10 +434,9 @@ export async function validateUrl (url: string, timeout: number = 5000): Promise
|
||||
const contentType = (res.headers['content-type'] as string) || '';
|
||||
const contentLength = parseInt((res.headers['content-length'] as string) || '0', 10);
|
||||
|
||||
// 验证条件
|
||||
// 简化验证条件
|
||||
const isValidStatus = statusCode >= 200 && statusCode < 400;
|
||||
const isNotHtmlError = !contentType.includes('text/html');
|
||||
const isValidSize = url.endsWith('.zip') ? contentLength > 1024 * 1024 : true;
|
||||
|
||||
if (!isValidStatus) {
|
||||
resolve({
|
||||
@ -458,14 +454,6 @@ export async function validateUrl (url: string, timeout: number = 5000): Promise
|
||||
contentLength,
|
||||
error: '返回了 HTML 页面而非文件',
|
||||
});
|
||||
} else if (!isValidSize) {
|
||||
resolve({
|
||||
valid: false,
|
||||
statusCode,
|
||||
contentType,
|
||||
contentLength,
|
||||
error: `文件过小 (${contentLength} bytes),可能是错误页面`,
|
||||
});
|
||||
} else {
|
||||
resolve({
|
||||
valid: true,
|
||||
@ -542,21 +530,21 @@ export async function findAvailableDownloadUrl (
|
||||
const testWithValidation = async (url: string): Promise<boolean> => {
|
||||
if (validateContent) {
|
||||
const result = await validateUrl(url, timeout);
|
||||
// 额外检查文件大小
|
||||
// 额外检查文件大小(仅当指定了 minFileSize 时)
|
||||
if (result.valid && minFileSize && result.contentLength && result.contentLength < minFileSize) {
|
||||
return false;
|
||||
}
|
||||
return result.valid;
|
||||
}
|
||||
return testMethod === 'head' ? testUrlHead(url, timeout) : testUrl(url, timeout);
|
||||
// 不验证内容,只检查状态码
|
||||
const isValid = testMethod === 'head' ? await testUrlHead(url, timeout) : await testUrl(url, timeout);
|
||||
return isValid;
|
||||
};
|
||||
|
||||
// 1. 如果设置了自定义镜像,优先使用
|
||||
// 1. 如果设置了自定义镜像,直接使用(不测试,信任用户选择)
|
||||
if (customMirror) {
|
||||
const customUrl = buildMirrorUrl(originalUrl, customMirror);
|
||||
if (await testWithValidation(customUrl)) {
|
||||
return customUrl;
|
||||
}
|
||||
return customUrl;
|
||||
}
|
||||
|
||||
// 2. 先测试原始 URL
|
||||
|
||||
@ -7,19 +7,15 @@ import { pipeline } from 'stream/promises';
|
||||
import { createWriteStream } from 'fs';
|
||||
import compressing from 'compressing';
|
||||
import { findAvailableDownloadUrl, GITHUB_RAW_MIRRORS } from 'napcat-common/src/mirror';
|
||||
import { webUiPathWrapper } from '@/napcat-webui-backend/index';
|
||||
|
||||
// 插件商店源配置
|
||||
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 });
|
||||
}
|
||||
// 插件目录 - 使用 pathWrapper
|
||||
const getPluginsDir = () => webUiPathWrapper.pluginPath;
|
||||
|
||||
// 插件列表缓存
|
||||
let pluginListCache: PluginStoreList | null = null;
|
||||
@ -80,7 +76,7 @@ async function fetchPluginList (): Promise<PluginStoreList> {
|
||||
* 下载文件,使用镜像系统
|
||||
* 自动识别 GitHub Release URL 并使用镜像加速
|
||||
*/
|
||||
async function downloadFile (url: string, destPath: string): Promise<void> {
|
||||
async function downloadFile (url: string, destPath: string, customMirror?: string): Promise<void> {
|
||||
try {
|
||||
let downloadUrl: string;
|
||||
|
||||
@ -91,25 +87,36 @@ async function downloadFile (url: string, destPath: string): Promise<void> {
|
||||
if (githubReleasePattern.test(url)) {
|
||||
// 使用镜像系统查找可用的下载 URL(支持 GitHub Release 镜像)
|
||||
console.log(`Detected GitHub Release URL: ${url}`);
|
||||
console.log(`Custom mirror: ${customMirror || 'auto'}`);
|
||||
|
||||
downloadUrl = await findAvailableDownloadUrl(url, {
|
||||
validateContent: true,
|
||||
minFileSize: 1024, // 最小 1KB
|
||||
timeout: 60000, // 60秒超时
|
||||
useFastMirrors: true, // 使用快速镜像列表
|
||||
validateContent: false, // 不验证内容,只检查状态码和 Content-Type
|
||||
timeout: 5000, // 每个镜像测试5秒超时
|
||||
useFastMirrors: false, // 不使用快速镜像列表(避免测速阻塞)
|
||||
customMirror: customMirror || undefined, // 使用用户选择的镜像
|
||||
});
|
||||
|
||||
console.log(`Selected download URL: ${downloadUrl}`);
|
||||
} else {
|
||||
// 其他URL直接下载
|
||||
console.log(`Direct download URL: ${url}`);
|
||||
downloadUrl = url;
|
||||
}
|
||||
|
||||
console.log(`Downloading from: ${downloadUrl}`);
|
||||
console.log(`Starting download from: ${downloadUrl}`);
|
||||
|
||||
// 确保目标目录存在
|
||||
const destDir = path.dirname(destPath);
|
||||
if (!fs.existsSync(destDir)) {
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
console.log(`Created directory: ${destDir}`);
|
||||
}
|
||||
|
||||
const response = await fetch(downloadUrl, {
|
||||
headers: {
|
||||
'User-Agent': 'NapCat-WebUI',
|
||||
},
|
||||
signal: AbortSignal.timeout(60000),
|
||||
signal: AbortSignal.timeout(120000), // 实际下载120秒超时
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@ -138,20 +145,38 @@ async function downloadFile (url: string, destPath: string): Promise<void> {
|
||||
* 解压插件到指定目录
|
||||
*/
|
||||
async function extractPlugin (zipPath: string, pluginId: string): Promise<void> {
|
||||
const PLUGINS_DIR = getPluginsDir();
|
||||
const pluginDir = path.join(PLUGINS_DIR, pluginId);
|
||||
|
||||
console.log(`[extractPlugin] PLUGINS_DIR: ${PLUGINS_DIR}`);
|
||||
console.log(`[extractPlugin] pluginId: ${pluginId}`);
|
||||
console.log(`[extractPlugin] Target directory: ${pluginDir}`);
|
||||
console.log(`[extractPlugin] Zip file: ${zipPath}`);
|
||||
|
||||
// 确保插件根目录存在
|
||||
if (!fs.existsSync(PLUGINS_DIR)) {
|
||||
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
|
||||
console.log(`[extractPlugin] Created plugins root directory: ${PLUGINS_DIR}`);
|
||||
}
|
||||
|
||||
// 如果目录已存在,先删除
|
||||
if (fs.existsSync(pluginDir)) {
|
||||
console.log(`[extractPlugin] Directory exists, removing: ${pluginDir}`);
|
||||
fs.rmSync(pluginDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// 创建插件目录
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
console.log(`[extractPlugin] Created directory: ${pluginDir}`);
|
||||
|
||||
// 解压
|
||||
await compressing.zip.uncompress(zipPath, pluginDir);
|
||||
|
||||
//console.log(`Plugin extracted to: ${pluginDir}`);
|
||||
console.log(`[extractPlugin] Plugin extracted to: ${pluginDir}`);
|
||||
|
||||
// 列出解压后的文件
|
||||
const files = fs.readdirSync(pluginDir);
|
||||
console.log(`[extractPlugin] Extracted files:`, files);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -186,11 +211,11 @@ export const GetPluginStoreDetailHandler: RequestHandler = async (req, res) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* 安装插件(从商店)
|
||||
* 安装插件(从商店)- 普通 POST 接口
|
||||
*/
|
||||
export const InstallPluginFromStoreHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.body;
|
||||
const { id, mirror } = req.body;
|
||||
|
||||
if (!id) {
|
||||
return sendError(res, 'Plugin ID is required');
|
||||
@ -205,10 +230,11 @@ export const InstallPluginFromStoreHandler: RequestHandler = async (req, res) =>
|
||||
}
|
||||
|
||||
// 下载插件
|
||||
const PLUGINS_DIR = getPluginsDir();
|
||||
const tempZipPath = path.join(PLUGINS_DIR, `${id}.temp.zip`);
|
||||
|
||||
try {
|
||||
await downloadFile(plugin.downloadUrl, tempZipPath);
|
||||
await downloadFile(plugin.downloadUrl, tempZipPath, mirror);
|
||||
|
||||
// 解压插件
|
||||
await extractPlugin(tempZipPath, id);
|
||||
@ -232,3 +258,83 @@ export const InstallPluginFromStoreHandler: RequestHandler = async (req, res) =>
|
||||
return sendError(res, 'Failed to install plugin: ' + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 安装插件(从商店)- SSE 版本,实时推送进度
|
||||
*/
|
||||
export const InstallPluginFromStoreSSEHandler: RequestHandler = async (req, res) => {
|
||||
const { id, mirror } = req.query;
|
||||
|
||||
if (!id || typeof id !== 'string') {
|
||||
res.status(400).json({ error: 'Plugin ID is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 设置 SSE 响应头
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.flushHeaders();
|
||||
|
||||
const sendProgress = (message: string, progress?: number) => {
|
||||
res.write(`data: ${JSON.stringify({ message, progress })}\n\n`);
|
||||
};
|
||||
|
||||
try {
|
||||
sendProgress('正在获取插件信息...', 10);
|
||||
|
||||
// 获取插件信息
|
||||
const data = await fetchPluginList();
|
||||
const plugin = data.plugins.find(p => p.id === id);
|
||||
|
||||
if (!plugin) {
|
||||
sendProgress('错误: 插件不存在', 0);
|
||||
res.write(`data: ${JSON.stringify({ error: 'Plugin not found in store' })}\n\n`);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
sendProgress(`找到插件: ${plugin.name} v${plugin.version}`, 20);
|
||||
sendProgress(`下载地址: ${plugin.downloadUrl}`, 25);
|
||||
|
||||
if (mirror && typeof mirror === 'string') {
|
||||
sendProgress(`使用镜像: ${mirror}`, 28);
|
||||
}
|
||||
|
||||
// 下载插件
|
||||
const PLUGINS_DIR = getPluginsDir();
|
||||
const tempZipPath = path.join(PLUGINS_DIR, `${id}.temp.zip`);
|
||||
|
||||
try {
|
||||
sendProgress('正在下载插件...', 30);
|
||||
await downloadFile(plugin.downloadUrl, tempZipPath, mirror as string | undefined);
|
||||
|
||||
sendProgress('下载完成,正在解压...', 70);
|
||||
await extractPlugin(tempZipPath, id);
|
||||
|
||||
sendProgress('解压完成,正在清理...', 90);
|
||||
fs.unlinkSync(tempZipPath);
|
||||
|
||||
sendProgress('安装成功!', 100);
|
||||
res.write(`data: ${JSON.stringify({
|
||||
success: true,
|
||||
message: 'Plugin installed successfully',
|
||||
plugin: plugin,
|
||||
installPath: path.join(PLUGINS_DIR, id),
|
||||
})}\n\n`);
|
||||
res.end();
|
||||
} catch (downloadError: any) {
|
||||
// 清理临时文件
|
||||
if (fs.existsSync(tempZipPath)) {
|
||||
fs.unlinkSync(tempZipPath);
|
||||
}
|
||||
sendProgress(`错误: ${downloadError.message}`, 0);
|
||||
res.write(`data: ${JSON.stringify({ error: downloadError.message })}\n\n`);
|
||||
res.end();
|
||||
}
|
||||
} catch (e: any) {
|
||||
sendProgress(`错误: ${e.message}`, 0);
|
||||
res.write(`data: ${JSON.stringify({ error: e.message })}\n\n`);
|
||||
res.end();
|
||||
}
|
||||
};
|
||||
|
||||
@ -1,6 +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';
|
||||
import { GetPluginStoreListHandler, GetPluginStoreDetailHandler, InstallPluginFromStoreHandler, InstallPluginFromStoreSSEHandler } from '@/napcat-webui-backend/src/api/PluginStore';
|
||||
|
||||
const router: Router = Router();
|
||||
|
||||
@ -13,5 +13,6 @@ router.post('/Uninstall', UninstallPluginHandler);
|
||||
router.get('/Store/List', GetPluginStoreListHandler);
|
||||
router.get('/Store/Detail/:id', GetPluginStoreDetailHandler);
|
||||
router.post('/Store/Install', InstallPluginFromStoreHandler);
|
||||
router.get('/Store/Install/SSE', InstallPluginFromStoreSSEHandler);
|
||||
|
||||
export { router as PluginRouter };
|
||||
|
||||
@ -50,7 +50,10 @@ export default class PluginManager {
|
||||
return data.data;
|
||||
}
|
||||
|
||||
public static async installPluginFromStore (id: string) {
|
||||
await serverRequest.post<ServerResponse<void>>('/Plugin/Store/Install', { id });
|
||||
public static async installPluginFromStore (id: string, mirror?: string) {
|
||||
// 插件安装可能需要较长时间(下载+解压),设置5分钟超时
|
||||
await serverRequest.post<ServerResponse<void>>('/Plugin/Store/Install', { id, mirror }, {
|
||||
timeout: 300000, // 5分钟
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,15 +1,21 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Input } from '@heroui/input';
|
||||
import { Select, SelectItem } from '@heroui/select';
|
||||
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 { useRequest } from 'ahooks';
|
||||
import { EventSourcePolyfill } from 'event-source-polyfill';
|
||||
|
||||
import PageLoading from '@/components/page_loading';
|
||||
import PluginStoreCard from '@/components/display_card/plugin_store_card';
|
||||
import PluginManager from '@/controllers/plugin_manager';
|
||||
import WebUIManager from '@/controllers/webui_manager';
|
||||
import { PluginStoreItem } from '@/types/plugin-store';
|
||||
import useDialog from '@/hooks/use-dialog';
|
||||
import key from '@/const/key';
|
||||
|
||||
interface EmptySectionProps {
|
||||
isEmpty: boolean;
|
||||
@ -32,6 +38,14 @@ export default function PluginStorePage () {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [activeTab, setActiveTab] = useState<string>('all');
|
||||
const dialog = useDialog();
|
||||
|
||||
// 获取镜像列表
|
||||
const { data: mirrorsData } = useRequest(WebUIManager.getMirrors, {
|
||||
cacheKey: 'napcat-mirrors',
|
||||
staleTime: 60 * 60 * 1000,
|
||||
});
|
||||
const mirrors = mirrorsData?.mirrors || [];
|
||||
|
||||
const loadPlugins = async () => {
|
||||
setLoading(true);
|
||||
@ -86,13 +100,116 @@ export default function PluginStorePage () {
|
||||
];
|
||||
}, [categorizedPlugins]);
|
||||
|
||||
const handleInstall = async (pluginId: string) => {
|
||||
const handleInstall = async (plugin: PluginStoreItem) => {
|
||||
// 检测是否是 GitHub 下载链接
|
||||
const githubPattern = /^https:\/\/github\.com\//;
|
||||
const isGitHubUrl = githubPattern.test(plugin.downloadUrl);
|
||||
|
||||
// 如果是 GitHub 链接,弹出镜像选择对话框
|
||||
if (isGitHubUrl) {
|
||||
let selectedMirror: string | undefined = undefined;
|
||||
|
||||
dialog.confirm({
|
||||
title: '安装插件',
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm mb-2">
|
||||
插件名称: <span className="font-semibold">{plugin.name}</span>
|
||||
</p>
|
||||
<p className="text-sm mb-2">
|
||||
版本: <span className="font-semibold">v{plugin.version}</span>
|
||||
</p>
|
||||
<p className="text-sm text-default-500 mb-4">
|
||||
{plugin.description}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">选择下载镜像源</label>
|
||||
<Select
|
||||
placeholder="自动选择 (默认)"
|
||||
defaultSelectedKeys={['default']}
|
||||
onSelectionChange={(keys) => {
|
||||
const m = Array.from(keys)[0] as string;
|
||||
selectedMirror = m === 'default' ? undefined : m;
|
||||
}}
|
||||
size="sm"
|
||||
aria-label="选择镜像源"
|
||||
>
|
||||
{['default', ...mirrors].map(m => (
|
||||
<SelectItem key={m} textValue={m === 'default' ? '自动选择' : m}>
|
||||
{m === 'default' ? '自动选择 (默认)' : m}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
confirmText: '开始安装',
|
||||
cancelText: '取消',
|
||||
onConfirm: async () => {
|
||||
await installPluginWithSSE(plugin.id, selectedMirror);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// 非 GitHub 链接,直接安装
|
||||
await installPluginWithSSE(plugin.id);
|
||||
}
|
||||
};
|
||||
|
||||
const installPluginWithSSE = async (pluginId: string, mirror?: string) => {
|
||||
const loadingToast = toast.loading('正在准备安装...');
|
||||
|
||||
try {
|
||||
await PluginManager.installPluginFromStore(pluginId);
|
||||
toast.success('插件安装成功!');
|
||||
// 可以选择刷新插件列表或导航到插件管理页面
|
||||
// 获取认证 token
|
||||
const token = localStorage.getItem(key.token);
|
||||
if (!token) {
|
||||
toast.error('未登录,请先登录', { id: loadingToast });
|
||||
return;
|
||||
}
|
||||
const _token = JSON.parse(token);
|
||||
|
||||
const params = new URLSearchParams({ id: pluginId });
|
||||
if (mirror) {
|
||||
params.append('mirror', mirror);
|
||||
}
|
||||
|
||||
const eventSource = new EventSourcePolyfill(
|
||||
`/api/Plugin/Store/Install/SSE?${params.toString()}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${_token}`,
|
||||
Accept: 'text/event-stream',
|
||||
},
|
||||
withCredentials: true,
|
||||
}
|
||||
);
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.error) {
|
||||
toast.error(`安装失败: ${data.error}`, { id: loadingToast });
|
||||
eventSource.close();
|
||||
} else if (data.success) {
|
||||
toast.success('插件安装成功!', { id: loadingToast });
|
||||
eventSource.close();
|
||||
} else if (data.message) {
|
||||
toast.loading(data.message, { id: loadingToast });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse SSE message:', e);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('SSE连接出错:', error);
|
||||
toast.error('连接中断,安装失败', { id: loadingToast });
|
||||
eventSource.close();
|
||||
};
|
||||
} catch (error: any) {
|
||||
toast.error(`安装失败: ${error.message || '未知错误'}`);
|
||||
toast.error(`安装失败: ${error.message || '未知错误'}`, { id: loadingToast });
|
||||
}
|
||||
};
|
||||
|
||||
@ -148,7 +265,7 @@ export default function PluginStorePage () {
|
||||
<PluginStoreCard
|
||||
key={plugin.id}
|
||||
data={plugin}
|
||||
onInstall={() => handleInstall(plugin.id)}
|
||||
onInstall={() => handleInstall(plugin)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user