From 5edafeed3e85fd390208c3d1b5c562dc812c520f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=97=B6=E7=91=BE?= <74231782+sj817@users.noreply.github.com> Date: Thu, 12 Feb 2026 15:24:58 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8F=92=E4=BB=B6=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E5=BC=95=E5=85=A5npm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/napcat-common/src/npm-registry.ts | 314 +++++++++ .../src/api/PluginStore.ts | 636 +++++++++++++++--- .../napcat-webui-backend/src/router/Plugin.ts | 12 +- .../src/types/PluginStore.ts | 9 +- .../display_card/plugin_store_card.tsx | 13 + .../src/controllers/plugin_manager.ts | 39 +- .../pages/dashboard/plugin_detail_modal.tsx | 87 ++- .../src/pages/dashboard/plugin_store.tsx | 386 ++++++++++- .../src/types/plugin-store.ts | 9 +- 9 files changed, 1388 insertions(+), 117 deletions(-) create mode 100644 packages/napcat-common/src/npm-registry.ts diff --git a/packages/napcat-common/src/npm-registry.ts b/packages/napcat-common/src/npm-registry.ts new file mode 100644 index 00000000..225d0995 --- /dev/null +++ b/packages/napcat-common/src/npm-registry.ts @@ -0,0 +1,314 @@ +/** + * npm 注册表工具模块 + * 提供从 npm registry 获取包信息和下载 tarball 的能力 + * 适用于 Electron 环境,不依赖系统安装的 npm CLI + * + * 设计目标: + * - 通过 HTTP API 直接与 npm registry 交互 + * - 支持多个 registry 镜像源 + * - 下载 tarball 并解压到指定目录(与现有 zip 安装流程一致) + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { pipeline } from 'stream/promises'; +import { createWriteStream } from 'fs'; + +// ============== npm Registry 镜像源 ============== + +/** + * npm Registry 镜像列表 + * 按优先级排序,优先使用国内镜像 + */ +export const NPM_REGISTRY_MIRRORS = [ + 'https://registry.npmmirror.com', // 淘宝镜像(国内首选) + 'https://registry.npmjs.org', // 官方源 +]; + +// ============== 类型定义 ============== + +/** npm 包的简要版本信息 */ +export interface NpmPackageVersionInfo { + name: string; + version: string; + description?: string; + author?: string | { name: string; email?: string; url?: string }; + homepage?: string; + repository?: string | { type: string; url: string }; + keywords?: string[]; + dist: { + tarball: string; + shasum?: string; + integrity?: string; + fileCount?: number; + unpackedSize?: number; + }; + /** 插件扩展字段 */ + napcat?: { + tags?: string[]; + minVersion?: string; + displayName?: string; + }; +} + +/** npm 包的完整元数据(简化版) */ +export interface NpmPackageMetadata { + name: string; + description?: string; + 'dist-tags': Record; + versions: Record; + time?: Record; + readme?: string; + homepage?: string; + repository?: string | { type: string; url: string }; + author?: string | { name: string; email?: string; url?: string }; + keywords?: string[]; +} + +// ============== 缓存 ============== + +interface MetadataCache { + data: NpmPackageMetadata; + timestamp: number; +} + +const metadataCache: Map = new Map(); +const METADATA_CACHE_TTL = 5 * 60 * 1000; // 5 分钟 + +// ============== 核心功能 ============== + +/** + * 从 npm registry 获取包的元数据 + * @param packageName 包名(如 "napcat-plugin-example") + * @param registry 指定的 registry URL(可选) + * @param forceRefresh 强制跳过缓存 + */ +export async function fetchNpmPackageMetadata ( + packageName: string, + registry?: string, + forceRefresh: boolean = false +): Promise { + // 检查缓存 + const cacheKey = `${registry || 'auto'}:${packageName}`; + if (!forceRefresh) { + const cached = metadataCache.get(cacheKey); + if (cached && (Date.now() - cached.timestamp) < METADATA_CACHE_TTL) { + return cached.data; + } + } + + const registries = registry ? [registry] : NPM_REGISTRY_MIRRORS; + const errors: string[] = []; + + for (const reg of registries) { + try { + const url = `${reg.replace(/\/$/, '')}/${encodeURIComponent(packageName)}`; + const response = await fetch(url, { + headers: { + Accept: 'application/json', + 'User-Agent': 'NapCat-PluginManager', + }, + signal: AbortSignal.timeout(15000), + }); + + if (response.status === 404) { + throw new Error(`包 "${packageName}" 在 npm 上不存在`); + } + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json() as NpmPackageMetadata; + + // 更新缓存 + metadataCache.set(cacheKey, { data, timestamp: Date.now() }); + + return data; + } catch (e: any) { + errors.push(`${reg}: ${e.message}`); + // 如果是 404,直接抛出,不再尝试其他镜像 + if (e.message.includes('不存在')) { + throw e; + } + } + } + + throw new Error(`获取 npm 包 "${packageName}" 信息失败:\n${errors.join('\n')}`); +} + +/** + * 获取包的最新版本信息 + */ +export async function fetchNpmLatestVersion ( + packageName: string, + registry?: string +): Promise { + const metadata = await fetchNpmPackageMetadata(packageName, registry); + const latestTag = metadata['dist-tags']?.['latest']; + + if (!latestTag) { + throw new Error(`包 "${packageName}" 没有 latest 标签`); + } + + const versionInfo = metadata.versions[latestTag]; + if (!versionInfo) { + throw new Error(`包 "${packageName}" 的 latest 版本 (${latestTag}) 信息不存在`); + } + + return versionInfo; +} + +/** + * 获取包的指定版本信息 + */ +export async function fetchNpmVersionInfo ( + packageName: string, + version: string, + registry?: string +): Promise { + const metadata = await fetchNpmPackageMetadata(packageName, registry); + const versionInfo = metadata.versions[version]; + + if (!versionInfo) { + const availableVersions = Object.keys(metadata.versions); + throw new Error( + `版本 "${version}" 不存在。可用版本: ${availableVersions.slice(-5).join(', ')}` + ); + } + + return versionInfo; +} + +/** + * 下载 npm tarball 文件 + * @param tarballUrl tarball 下载地址 + * @param destPath 保存路径 + * @param registry 用于替换 tarball URL 中的 registry 域名(镜像加速) + * @param onProgress 进度回调 + * @param timeout 超时时间(毫秒) + */ +export async function downloadNpmTarball ( + tarballUrl: string, + destPath: string, + registry?: string, + onProgress?: (percent: number, downloaded: number, total: number, speed: number) => void, + timeout: number = 120000 +): Promise { + // 如果指定了 registry,替换 tarball URL 中的域名 + let downloadUrl = tarballUrl; + if (registry) { + // tarball URL 通常是 https://registry.npmjs.org/package/-/package-1.0.0.tgz + // 替换为 https://registry.npmmirror.com/package/-/package-1.0.0.tgz + try { + const tarballUrlObj = new URL(tarballUrl); + const registryUrlObj = new URL(registry); + tarballUrlObj.hostname = registryUrlObj.hostname; + tarballUrlObj.protocol = registryUrlObj.protocol; + if (registryUrlObj.port) { + tarballUrlObj.port = registryUrlObj.port; + } + downloadUrl = tarballUrlObj.toString(); + } catch { + // URL 解析失败,使用原始 URL + } + } + + try { + // 确保目标目录存在 + const destDir = path.dirname(destPath); + if (!fs.existsSync(destDir)) { + fs.mkdirSync(destDir, { recursive: true }); + } + + if (onProgress) { + onProgress(0, 0, 0, 0); + } + + const response = await fetch(downloadUrl, { + headers: { + 'User-Agent': 'NapCat-PluginManager', + }, + signal: AbortSignal.timeout(timeout), + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + if (!response.body) { + throw new Error('Response body is null'); + } + + const totalLength = Number(response.headers.get('content-length')) || 0; + let downloaded = 0; + let lastTime = Date.now(); + let lastDownloaded = 0; + + // 进度监控 + // eslint-disable-next-line @stylistic/generator-star-spacing + const progressMonitor = async function* (source: any) { + for await (const chunk of source) { + downloaded += chunk.length; + const now = Date.now(); + const elapsed = now - lastTime; + + if (elapsed >= 500 || (totalLength && downloaded === totalLength)) { + const percent = totalLength ? Math.round((downloaded / totalLength) * 100) : 0; + const speed = (downloaded - lastDownloaded) / (elapsed / 1000); + + if (onProgress) { + onProgress(percent, downloaded, totalLength, speed); + } + + lastTime = now; + lastDownloaded = downloaded; + } + + yield chunk; + } + }; + + const fileStream = createWriteStream(destPath); + await pipeline(progressMonitor(response.body), fileStream); + } catch (e: any) { + if (fs.existsSync(destPath)) { + fs.unlinkSync(destPath); + } + throw new Error(`下载 npm 包失败: ${e.message}`); + } +} + +/** + * 从 npm 包的元数据中提取作者名 + */ +export function extractAuthorName ( + author?: string | { name: string; email?: string; url?: string } +): string { + if (!author) return 'unknown'; + if (typeof author === 'string') return author; + return author.name || 'unknown'; +} + +/** + * 从 npm 包的元数据中提取 homepage + */ +export function extractHomepage ( + homepage?: string, + repository?: string | { type: string; url: string } +): string | undefined { + if (homepage) return homepage; + if (!repository) return undefined; + if (typeof repository === 'string') return repository; + // 转换 git+https://github.com/xxx/yyy.git → https://github.com/xxx/yyy + return repository.url + ?.replace(/^git\+/, '') + ?.replace(/\.git$/, ''); +} + +/** + * 清除 npm 元数据缓存 + */ +export function clearNpmMetadataCache (): void { + metadataCache.clear(); +} diff --git a/packages/napcat-webui-backend/src/api/PluginStore.ts b/packages/napcat-webui-backend/src/api/PluginStore.ts index 974f9238..ee3566a2 100644 --- a/packages/napcat-webui-backend/src/api/PluginStore.ts +++ b/packages/napcat-webui-backend/src/api/PluginStore.ts @@ -1,12 +1,21 @@ import { RequestHandler } from 'express'; import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/response'; -import { PluginStoreList } from '@/napcat-webui-backend/src/types/PluginStore'; +import { PluginStoreList, PluginStoreItem } 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'; +import { + fetchNpmPackageMetadata, + fetchNpmLatestVersion, + fetchNpmVersionInfo, + downloadNpmTarball, + extractAuthorName, + extractHomepage, + NPM_REGISTRY_MIRRORS, +} from 'napcat-common/src/npm-registry'; import { webUiPathWrapper } from '@/napcat-webui-backend/index'; import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data'; import { NapCatOneBot11Adapter } from '@/napcat-onebot/index'; @@ -287,6 +296,92 @@ async function extractPlugin (zipPath: string, pluginId: string): Promise console.log('[extractPlugin] Extracted files:', files); } +/** + * 解压 npm tarball (.tgz) 到指定目录 + * npm tarball 解压后通常有一个 "package/" 前缀目录,需要去掉 + */ +async function extractNpmTarball (tgzPath: string, pluginId: string): Promise { + const safeId = validatePluginId(pluginId); + const PLUGINS_DIR = getPluginsDir(); + const pluginDir = path.join(PLUGINS_DIR, safeId); + const dataDir = path.join(pluginDir, 'data'); + const tempDataDir = path.join(PLUGINS_DIR, `${safeId}.data.backup`); + const tempExtractDir = path.join(PLUGINS_DIR, `${safeId}.npm.temp`); + + console.log(`[extractNpmTarball] pluginId: ${safeId}, tgz: ${tgzPath}`); + + if (!fs.existsSync(PLUGINS_DIR)) { + fs.mkdirSync(PLUGINS_DIR, { recursive: true }); + } + + // 备份 data 目录 + let hasDataBackup = false; + if (fs.existsSync(pluginDir)) { + if (fs.existsSync(dataDir)) { + if (fs.existsSync(tempDataDir)) { + fs.rmSync(tempDataDir, { recursive: true, force: true }); + } + fs.renameSync(dataDir, tempDataDir); + hasDataBackup = true; + } + fs.rmSync(pluginDir, { recursive: true, force: true }); + } + + // 创建临时解压目录 + if (fs.existsSync(tempExtractDir)) { + fs.rmSync(tempExtractDir, { recursive: true, force: true }); + } + fs.mkdirSync(tempExtractDir, { recursive: true }); + + try { + // 解压 tgz(npm tarball 格式) + await compressing.tgz.uncompress(tgzPath, tempExtractDir); + + // npm tarball 解压后通常有 "package/" 目录 + const extractedItems = fs.readdirSync(tempExtractDir); + let sourceDir = tempExtractDir; + + if (extractedItems.length === 1 && extractedItems[0]) { + const singleDir = path.join(tempExtractDir, extractedItems[0]); + if (fs.statSync(singleDir).isDirectory()) { + sourceDir = singleDir; + } + } + + // 移动到目标目录 + fs.renameSync(sourceDir, pluginDir); + + // 清理临时目录 + if (sourceDir !== tempExtractDir && fs.existsSync(tempExtractDir)) { + fs.rmSync(tempExtractDir, { recursive: true, force: true }); + } + + // 恢复 data 目录 + if (hasDataBackup && fs.existsSync(tempDataDir)) { + if (fs.existsSync(dataDir)) { + fs.rmSync(dataDir, { recursive: true, force: true }); + } + fs.renameSync(tempDataDir, dataDir); + } + + console.log(`[extractNpmTarball] Extracted npm package to: ${pluginDir}`); + } catch (e) { + if (fs.existsSync(tempExtractDir)) { + fs.rmSync(tempExtractDir, { recursive: true, force: true }); + } + if (hasDataBackup && fs.existsSync(tempDataDir)) { + if (!fs.existsSync(pluginDir)) { + fs.mkdirSync(pluginDir, { recursive: true }); + } + if (fs.existsSync(dataDir)) { + fs.rmSync(dataDir, { recursive: true, force: true }); + } + fs.renameSync(tempDataDir, dataDir); + } + throw e; + } +} + /** * 获取插件商店列表 */ @@ -322,10 +417,11 @@ export const GetPluginStoreDetailHandler: RequestHandler = async (req, res) => { /** * 安装插件(从商店)- 普通 POST 接口 + * 支持 npm 和 github 两种来源 */ export const InstallPluginFromStoreHandler: RequestHandler = async (req, res) => { try { - const { id: rawId, mirror } = req.body; + const { id: rawId, mirror, registry } = req.body; if (!rawId) { return sendError(res, 'Plugin ID is required'); @@ -350,41 +446,62 @@ export const InstallPluginFromStoreHandler: RequestHandler = async (req, res) => } } - // 下载插件 const PLUGINS_DIR = getPluginsDir(); - const tempZipPath = path.join(PLUGINS_DIR, `${id}.temp.zip`); + const isNpmSource = plugin.source === 'npm' && plugin.npmPackage; - try { - await downloadFile(plugin.downloadUrl, tempZipPath, mirror, undefined, 300000); + if (isNpmSource) { + // npm 安装流程 + const tempTgzPath = path.join(PLUGINS_DIR, `${id}.temp.tgz`); + try { + const versionInfo = await fetchNpmLatestVersion(plugin.npmPackage!, registry); + await downloadNpmTarball(versionInfo.dist.tarball, tempTgzPath, registry, undefined, 300000); + await extractNpmTarball(tempTgzPath, id); + fs.unlinkSync(tempTgzPath); - // 解压插件 - await extractPlugin(tempZipPath, id); - - // 删除临时文件 - fs.unlinkSync(tempZipPath); - - // 如果 pluginManager 存在,立即注册或重载插件 - const pluginManager = getPluginManager(); - if (pluginManager) { - // 如果插件已存在,则重载以刷新版本信息;否则注册新插件 - if (pluginManager.getPluginInfo(id)) { - await pluginManager.reloadPlugin(id); - } else { - await pluginManager.loadPluginById(id); + const pluginManager = getPluginManager(); + if (pluginManager) { + if (pluginManager.getPluginInfo(id)) { + await pluginManager.reloadPlugin(id); + } else { + await pluginManager.loadPluginById(id); + } } - } - return sendSuccess(res, { - message: 'Plugin installed successfully', - plugin, - installPath: path.join(PLUGINS_DIR, id), - }); - } catch (downloadError: any) { - // 清理临时文件 - if (fs.existsSync(tempZipPath)) { - fs.unlinkSync(tempZipPath); + return sendSuccess(res, { + message: 'Plugin installed successfully (npm)', + plugin, + installPath: path.join(PLUGINS_DIR, id), + }); + } catch (e: any) { + if (fs.existsSync(tempTgzPath)) fs.unlinkSync(tempTgzPath); + throw e; + } + } else { + // GitHub 安装流程(向后兼容) + const tempZipPath = path.join(PLUGINS_DIR, `${id}.temp.zip`); + try { + await downloadFile(plugin.downloadUrl, tempZipPath, mirror, undefined, 300000); + await extractPlugin(tempZipPath, id); + fs.unlinkSync(tempZipPath); + + const pluginManager = getPluginManager(); + if (pluginManager) { + if (pluginManager.getPluginInfo(id)) { + await pluginManager.reloadPlugin(id); + } else { + await pluginManager.loadPluginById(id); + } + } + + return sendSuccess(res, { + message: 'Plugin installed successfully', + plugin, + installPath: path.join(PLUGINS_DIR, id), + }); + } catch (downloadError: any) { + if (fs.existsSync(tempZipPath)) fs.unlinkSync(tempZipPath); + throw downloadError; } - throw downloadError; } } catch (e: any) { return sendError(res, 'Failed to install plugin: ' + e.message); @@ -393,9 +510,10 @@ export const InstallPluginFromStoreHandler: RequestHandler = async (req, res) => /** * 安装插件(从商店)- SSE 版本,实时推送进度 + * 支持 npm 和 github 两种来源 */ export const InstallPluginFromStoreSSEHandler: RequestHandler = async (req, res) => { - const { id: rawId, mirror } = req.query; + const { id: rawId, mirror, registry } = req.query; if (!rawId || typeof rawId !== 'string') { res.status(400).json({ error: 'Plugin ID is required' }); @@ -447,71 +565,145 @@ export const InstallPluginFromStoreSSEHandler: RequestHandler = async (req, res) } sendProgress(`找到插件: ${plugin.name} v${plugin.version}`, 20); - sendProgress(`下载地址: ${plugin.downloadUrl}`, 25); - if (mirror && typeof mirror === 'string') { - sendProgress(`使用镜像: ${mirror}`, 28); - } - - // 下载插件 + const isNpmSource = plugin.source === 'npm' && plugin.npmPackage; 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, (percent, downloaded, total, speed) => { - const overallProgress = 30 + Math.round(percent * 0.5); - const downloadedMb = (downloaded / 1024 / 1024).toFixed(1); - const totalMb = total ? (total / 1024 / 1024).toFixed(1) : '?'; - const speedMb = (speed / 1024 / 1024).toFixed(2); - const eta = (total > 0 && speed > 0) ? Math.round((total - downloaded) / speed) : -1; + if (isNpmSource) { + // ========== npm 安装流程 ========== + const npmRegistry = (registry && typeof registry === 'string') ? registry : undefined; + sendProgress(`来源: npm (${plugin.npmPackage})`, 25); - sendProgress(`正在下载插件... ${percent}%`, overallProgress, { - downloaded, - total, - speed, - eta, - downloadedStr: `${downloadedMb}MB`, - totalStr: `${totalMb}MB`, - speedStr: `${speedMb}MB/s`, - }); - }, 300000); + if (npmRegistry) { + sendProgress(`使用 npm 镜像: ${npmRegistry}`, 28); + } - sendProgress('下载完成,正在解压...', 85); - await extractPlugin(tempZipPath, id); + const tempTgzPath = path.join(PLUGINS_DIR, `${id}.temp.tgz`); - sendProgress('解压完成,正在清理...', 95); - fs.unlinkSync(tempZipPath); + try { + sendProgress('正在从 npm 获取版本信息...', 30); + const versionInfo = await fetchNpmLatestVersion(plugin.npmPackage!, npmRegistry); + sendProgress(`tarball: ${versionInfo.dist.tarball}`, 35); - // 如果 pluginManager 存在,立即注册或重载插件 - const pluginManager = getPluginManager(); - if (pluginManager) { - // 如果插件已存在,则重载以刷新版本信息;否则注册新插件 - if (pluginManager.getPluginInfo(id)) { - sendProgress('正在刷新插件信息...', 95); - await pluginManager.reloadPlugin(id); - } else { - sendProgress('正在注册插件...', 95); - await pluginManager.loadPluginById(id); + sendProgress('正在下载插件包...', 40); + await downloadNpmTarball( + versionInfo.dist.tarball, + tempTgzPath, + npmRegistry, + (percent, downloaded, total, speed) => { + const overallProgress = 40 + Math.round(percent * 0.4); + const downloadedMb = (downloaded / 1024 / 1024).toFixed(1); + const totalMb = total ? (total / 1024 / 1024).toFixed(1) : '?'; + const speedMb = (speed / 1024 / 1024).toFixed(2); + const eta = (total > 0 && speed > 0) ? Math.round((total - downloaded) / speed) : -1; + + sendProgress(`正在下载插件... ${percent}%`, overallProgress, { + downloaded, + total, + speed, + eta, + downloadedStr: `${downloadedMb}MB`, + totalStr: `${totalMb}MB`, + speedStr: `${speedMb}MB/s`, + }); + }, + 300000, + ); + + sendProgress('下载完成,正在解压 npm 包...', 85); + await extractNpmTarball(tempTgzPath, id); + + sendProgress('解压完成,正在清理...', 95); + fs.unlinkSync(tempTgzPath); + + // 注册到 pluginManager + const pluginManager = getPluginManager(); + if (pluginManager) { + if (pluginManager.getPluginInfo(id)) { + sendProgress('正在刷新插件信息...', 95); + await pluginManager.reloadPlugin(id); + } else { + sendProgress('正在注册插件...', 95); + await pluginManager.loadPluginById(id); + } } + + sendProgress('安装成功!', 100); + res.write(`data: ${JSON.stringify({ + success: true, + message: 'Plugin installed successfully (npm)', + plugin, + installPath: path.join(PLUGINS_DIR, id), + })}\n\n`); + res.end(); + } catch (downloadError: any) { + if (fs.existsSync(tempTgzPath)) fs.unlinkSync(tempTgzPath); + sendProgress(`错误: ${downloadError.message}`, 0); + res.write(`data: ${JSON.stringify({ error: downloadError.message })}\n\n`); + res.end(); + } + } else { + // ========== GitHub 安装流程(向后兼容)========== + sendProgress(`来源: GitHub`, 25); + sendProgress(`下载地址: ${plugin.downloadUrl}`, 25); + + if (mirror && typeof mirror === 'string') { + sendProgress(`使用镜像: ${mirror}`, 28); } - sendProgress('安装成功!', 100); - res.write(`data: ${JSON.stringify({ - success: true, - message: 'Plugin installed successfully', - plugin, - installPath: path.join(PLUGINS_DIR, id), - })}\n\n`); - res.end(); - } catch (downloadError: any) { - // 清理临时文件 - if (fs.existsSync(tempZipPath)) { + const tempZipPath = path.join(PLUGINS_DIR, `${id}.temp.zip`); + + try { + sendProgress('正在下载插件...', 30); + await downloadFile(plugin.downloadUrl, tempZipPath, mirror as string | undefined, (percent, downloaded, total, speed) => { + const overallProgress = 30 + Math.round(percent * 0.5); + const downloadedMb = (downloaded / 1024 / 1024).toFixed(1); + const totalMb = total ? (total / 1024 / 1024).toFixed(1) : '?'; + const speedMb = (speed / 1024 / 1024).toFixed(2); + const eta = (total > 0 && speed > 0) ? Math.round((total - downloaded) / speed) : -1; + + sendProgress(`正在下载插件... ${percent}%`, overallProgress, { + downloaded, + total, + speed, + eta, + downloadedStr: `${downloadedMb}MB`, + totalStr: `${totalMb}MB`, + speedStr: `${speedMb}MB/s`, + }); + }, 300000); + + sendProgress('下载完成,正在解压...', 85); + await extractPlugin(tempZipPath, id); + + sendProgress('解压完成,正在清理...', 95); fs.unlinkSync(tempZipPath); + + const pluginManager = getPluginManager(); + if (pluginManager) { + if (pluginManager.getPluginInfo(id)) { + sendProgress('正在刷新插件信息...', 95); + await pluginManager.reloadPlugin(id); + } else { + sendProgress('正在注册插件...', 95); + await pluginManager.loadPluginById(id); + } + } + + sendProgress('安装成功!', 100); + res.write(`data: ${JSON.stringify({ + success: true, + message: 'Plugin installed successfully', + 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(); } - sendProgress(`错误: ${downloadError.message}`, 0); - res.write(`data: ${JSON.stringify({ error: downloadError.message })}\n\n`); - res.end(); } } catch (e: any) { sendProgress(`错误: ${e.message}`, 0); @@ -519,3 +711,277 @@ export const InstallPluginFromStoreSSEHandler: RequestHandler = async (req, res) res.end(); } }; + +/** + * 从 npm 直接安装插件(不依赖商店索引) + * 通过 npm 包名直接安装 + */ +export const InstallPluginFromNpmHandler: RequestHandler = async (req, res) => { + try { + const { packageName, version, registry } = req.body; + + if (!packageName || typeof packageName !== 'string') { + return sendError(res, 'npm 包名不能为空'); + } + + // 验证包名格式(npm 包名规则) + if (!/^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/.test(packageName)) { + return sendError(res, '无效的 npm 包名格式'); + } + + const PLUGINS_DIR = getPluginsDir(); + const tempTgzPath = path.join(PLUGINS_DIR, `${packageName.replace(/\//g, '-')}.temp.tgz`); + + try { + // 获取版本信息 + let versionInfo; + if (version) { + versionInfo = await fetchNpmVersionInfo(packageName, version, registry); + } else { + versionInfo = await fetchNpmLatestVersion(packageName, registry); + } + + const pluginId = versionInfo.name; + + // 检查是否已安装相同版本 + const pm = getPluginManager(); + if (pm) { + const installedInfo = pm.getPluginInfo(pluginId); + if (installedInfo && installedInfo.version === versionInfo.version) { + return sendError(res, '该插件已安装且版本相同,无需重复安装'); + } + } + + // 下载并解压 + await downloadNpmTarball(versionInfo.dist.tarball, tempTgzPath, registry, undefined, 300000); + await extractNpmTarball(tempTgzPath, pluginId); + fs.unlinkSync(tempTgzPath); + + // 注册 + const pluginManager = getPluginManager(); + if (pluginManager) { + if (pluginManager.getPluginInfo(pluginId)) { + await pluginManager.reloadPlugin(pluginId); + } else { + await pluginManager.loadPluginById(pluginId); + } + } + + return sendSuccess(res, { + message: 'Plugin installed successfully from npm', + pluginId, + version: versionInfo.version, + installPath: path.join(PLUGINS_DIR, pluginId), + }); + } catch (e: any) { + if (fs.existsSync(tempTgzPath)) fs.unlinkSync(tempTgzPath); + throw e; + } + } catch (e: any) { + return sendError(res, '从 npm 安装插件失败: ' + e.message); + } +}; + +/** + * 从 npm 直接安装插件 - SSE 版本 + */ +export const InstallPluginFromNpmSSEHandler: RequestHandler = async (req, res) => { + const { packageName, version, registry } = req.query; + + if (!packageName || typeof packageName !== 'string') { + res.status(400).json({ error: 'npm 包名不能为空' }); + return; + } + + if (!/^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/.test(packageName)) { + res.status(400).json({ error: '无效的 npm 包名格式' }); + 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, detail?: any) => { + res.write(`data: ${JSON.stringify({ message, progress, ...detail })}\n\n`); + }; + + const PLUGINS_DIR = getPluginsDir(); + const npmRegistry = (registry && typeof registry === 'string') ? registry : undefined; + const tempTgzPath = path.join(PLUGINS_DIR, `${packageName.replace(/\//g, '-')}.temp.tgz`); + + try { + sendProgress('正在从 npm 获取包信息...', 10); + + let versionInfo; + if (version && typeof version === 'string') { + versionInfo = await fetchNpmVersionInfo(packageName, version, npmRegistry); + } else { + versionInfo = await fetchNpmLatestVersion(packageName, npmRegistry); + } + + const pluginId = versionInfo.name; + sendProgress(`找到包: ${pluginId} v${versionInfo.version}`, 20); + + // 检查版本 + const pm = getPluginManager(); + if (pm) { + const installedInfo = pm.getPluginInfo(pluginId); + if (installedInfo && installedInfo.version === versionInfo.version) { + sendProgress('错误: 该插件已安装且版本相同', 0); + res.write(`data: ${JSON.stringify({ error: '该插件已安装且版本相同,无需重复安装' })}\n\n`); + res.end(); + return; + } + } + + sendProgress(`tarball: ${versionInfo.dist.tarball}`, 25); + if (npmRegistry) { + sendProgress(`使用 npm 镜像: ${npmRegistry}`, 28); + } + + sendProgress('正在下载插件包...', 30); + await downloadNpmTarball( + versionInfo.dist.tarball, + tempTgzPath, + npmRegistry, + (percent, downloaded, total, speed) => { + const overallProgress = 30 + Math.round(percent * 0.5); + const downloadedMb = (downloaded / 1024 / 1024).toFixed(1); + const totalMb = total ? (total / 1024 / 1024).toFixed(1) : '?'; + const speedMb = (speed / 1024 / 1024).toFixed(2); + const eta = (total > 0 && speed > 0) ? Math.round((total - downloaded) / speed) : -1; + + sendProgress(`正在下载... ${percent}%`, overallProgress, { + downloaded, total, speed, eta, + downloadedStr: `${downloadedMb}MB`, + totalStr: `${totalMb}MB`, + speedStr: `${speedMb}MB/s`, + }); + }, + 300000, + ); + + sendProgress('下载完成,正在解压...', 85); + await extractNpmTarball(tempTgzPath, pluginId); + + sendProgress('解压完成,正在清理...', 95); + fs.unlinkSync(tempTgzPath); + + const pluginManager = getPluginManager(); + if (pluginManager) { + if (pluginManager.getPluginInfo(pluginId)) { + sendProgress('正在刷新插件信息...', 95); + await pluginManager.reloadPlugin(pluginId); + } else { + sendProgress('正在注册插件...', 95); + await pluginManager.loadPluginById(pluginId); + } + } + + sendProgress('安装成功!', 100); + res.write(`data: ${JSON.stringify({ + success: true, + message: 'Plugin installed successfully from npm', + pluginId, + version: versionInfo.version, + installPath: path.join(PLUGINS_DIR, pluginId), + })}\n\n`); + res.end(); + } catch (e: any) { + if (fs.existsSync(tempTgzPath)) fs.unlinkSync(tempTgzPath); + sendProgress(`错误: ${e.message}`, 0); + res.write(`data: ${JSON.stringify({ error: e.message })}\n\n`); + res.end(); + } +}; + +/** + * 搜索 npm 上的 NapCat 插件 + * 使用 npm search API 搜索带有特定关键字的包 + */ +export const SearchNpmPluginsHandler: RequestHandler = async (req, res) => { + try { + const keyword = (req.query['keyword'] as string) || 'napcat-plugin'; + const registry = (req.query['registry'] as string) || NPM_REGISTRY_MIRRORS[0]; + const from = parseInt(req.query['from'] as string) || 0; + const size = Math.min(parseInt(req.query['size'] as string) || 20, 50); + + // npm search API: /-/v1/search?text=keyword + const searchUrl = `${registry?.replace(/\/$/, '')}/-/v1/search?text=${encodeURIComponent(keyword)}&from=${from}&size=${size}`; + + const response = await fetch(searchUrl, { + headers: { + 'Accept': 'application/json', + 'User-Agent': 'NapCat-PluginManager', + }, + signal: AbortSignal.timeout(15000), + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const searchResult = await response.json() as any; + + // 转换为 PluginStoreItem 格式 + const plugins: PluginStoreItem[] = (searchResult.objects || []).map((obj: any) => { + const pkg = obj.package; + return { + id: pkg.name, + name: pkg.napcat?.displayName || pkg.name, + version: pkg.version, + description: pkg.description || '', + author: extractAuthorName(pkg.author || (pkg.publisher ? pkg.publisher.username : undefined)), + homepage: extractHomepage(pkg.links?.homepage, pkg.links?.repository), + downloadUrl: '', // npm 源不需要 downloadUrl + tags: pkg.keywords || [], + source: 'npm' as const, + npmPackage: pkg.name, + }; + }); + + return sendSuccess(res, { + total: searchResult.total || 0, + plugins, + }); + } catch (e: any) { + return sendError(res, '搜索 npm 插件失败: ' + e.message); + } +}; + +/** + * 获取 npm 包详情(版本列表、README 等) + */ +export const GetNpmPluginDetailHandler: RequestHandler = async (req, res) => { + try { + const packageName = req.params['packageName']; + const registry = req.query['registry'] as string | undefined; + + if (!packageName) { + return sendError(res, '包名不能为空'); + } + + const metadata = await fetchNpmPackageMetadata(packageName, registry); + const latestVersion = metadata['dist-tags']?.['latest'] || ''; + const latestInfo = latestVersion ? metadata.versions[latestVersion] : null; + + return sendSuccess(res, { + name: metadata.name, + description: metadata.description || '', + latestVersion, + author: extractAuthorName(metadata.author), + homepage: extractHomepage(metadata.homepage, metadata.repository), + readme: metadata.readme || '', + versions: Object.keys(metadata.versions).reverse().slice(0, 20), + keywords: metadata.keywords || [], + tarball: latestInfo?.dist?.tarball || '', + unpackedSize: latestInfo?.dist?.unpackedSize, + napcat: latestInfo?.napcat, + }); + } catch (e: any) { + return sendError(res, '获取 npm 包详情失败: ' + e.message); + } +}; diff --git a/packages/napcat-webui-backend/src/router/Plugin.ts b/packages/napcat-webui-backend/src/router/Plugin.ts index bb27847f..929cc37e 100644 --- a/packages/napcat-webui-backend/src/router/Plugin.ts +++ b/packages/napcat-webui-backend/src/router/Plugin.ts @@ -18,7 +18,11 @@ import { GetPluginStoreListHandler, GetPluginStoreDetailHandler, InstallPluginFromStoreHandler, - InstallPluginFromStoreSSEHandler + InstallPluginFromStoreSSEHandler, + InstallPluginFromNpmHandler, + InstallPluginFromNpmSSEHandler, + SearchNpmPluginsHandler, + GetNpmPluginDetailHandler, } from '@/napcat-webui-backend/src/api/PluginStore'; import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data'; import { NapCatOneBot11Adapter } from '@/napcat-onebot/index'; @@ -75,6 +79,12 @@ router.get('/Store/Detail/:id', GetPluginStoreDetailHandler); router.post('/Store/Install', InstallPluginFromStoreHandler); router.get('/Store/Install/SSE', InstallPluginFromStoreSSEHandler); +// npm 插件安装相关路由 +router.post('/Npm/Install', InstallPluginFromNpmHandler); +router.get('/Npm/Install/SSE', InstallPluginFromNpmSSEHandler); +router.get('/Npm/Search', SearchNpmPluginsHandler); +router.get('/Npm/Detail/:packageName', GetNpmPluginDetailHandler); + // 插件扩展路由 - 动态挂载插件注册的 API 路由 router.use('/ext/:pluginId', (req, res, next): void => { const { pluginId } = req.params; diff --git a/packages/napcat-webui-backend/src/types/PluginStore.ts b/packages/napcat-webui-backend/src/types/PluginStore.ts index 4debee6b..94a86bc8 100644 --- a/packages/napcat-webui-backend/src/types/PluginStore.ts +++ b/packages/napcat-webui-backend/src/types/PluginStore.ts @@ -1,5 +1,8 @@ // 插件商店相关类型定义 +/** 插件来源类型 */ +export type PluginSourceType = 'npm' | 'github'; + export interface PluginStoreItem { id: string; // 插件唯一标识 name: string; // 插件名称 @@ -7,9 +10,13 @@ export interface PluginStoreItem { description: string; // 插件描述 author: string; // 作者 homepage?: string; // 主页链接 - downloadUrl: string; // 下载地址 + downloadUrl: string; // 下载地址(GitHub 模式兼容) tags?: string[]; // 标签 minVersion?: string; // 最低版本要求 + /** 插件来源类型,默认 'github' 保持向后兼容 */ + source?: PluginSourceType; + /** npm 包名(当 source 为 'npm' 时使用) */ + npmPackage?: string; } export interface PluginStoreList { diff --git a/packages/napcat-webui-frontend/src/components/display_card/plugin_store_card.tsx b/packages/napcat-webui-frontend/src/components/display_card/plugin_store_card.tsx index 3a3424c9..39a533c7 100644 --- a/packages/napcat-webui-frontend/src/components/display_card/plugin_store_card.tsx +++ b/packages/napcat-webui-frontend/src/components/display_card/plugin_store_card.tsx @@ -185,6 +185,19 @@ const PluginStoreCard: React.FC = ({ v{version} + {/* 来源标识 */} + {data.source === 'npm' && ( + + npm + + )} + {/* Tags with proper truncation and hover */} {tags?.slice(0, 2).map((tag) => ( { + public static async installPluginFromStore (id: string, mirror?: string, registry?: string): Promise { await serverRequest.post>( '/Plugin/Store/Install', - { id, mirror }, + { id, mirror, registry }, { timeout: 300000 } // 5分钟超时 ); } + // ==================== npm 插件安装 ==================== + + /** npm 搜索结果 */ + public static async searchNpmPlugins ( + keyword: string = 'napcat-plugin', + registry?: string, + from: number = 0, + size: number = 20, + ): Promise<{ total: number; plugins: PluginStoreItem[]; }> { + const params: Record = { keyword, from: String(from), size: String(size) }; + if (registry) params['registry'] = registry; + const { data } = await serverRequest.get>('/Plugin/Npm/Search', { params }); + return data.data; + } + + /** 获取 npm 包详情 */ + public static async getNpmPluginDetail (packageName: string, registry?: string): Promise { + const params: Record = {}; + if (registry) params['registry'] = registry; + const { data } = await serverRequest.get>(`/Plugin/Npm/Detail/${encodeURIComponent(packageName)}`, { params }); + return data.data; + } + + /** 从 npm 直接安装插件 */ + public static async installPluginFromNpm (packageName: string, version?: string, registry?: string): Promise { + await serverRequest.post>( + '/Plugin/Npm/Install', + { packageName, version, registry }, + { timeout: 300000 }, + ); + } + // ==================== 插件配置 ==================== /** diff --git a/packages/napcat-webui-frontend/src/pages/dashboard/plugin_detail_modal.tsx b/packages/napcat-webui-frontend/src/pages/dashboard/plugin_detail_modal.tsx index 98649831..09fa7d7a 100644 --- a/packages/napcat-webui-frontend/src/pages/dashboard/plugin_detail_modal.tsx +++ b/packages/napcat-webui-frontend/src/pages/dashboard/plugin_detail_modal.tsx @@ -6,11 +6,13 @@ import { Tooltip } from '@heroui/tooltip'; import { Spinner } from '@heroui/spinner'; import { IoMdCheckmarkCircle, IoMdOpen, IoMdDownload } from 'react-icons/io'; import { MdUpdate } from 'react-icons/md'; +import { FaNpm } from 'react-icons/fa'; import { useState, useEffect } from 'react'; import { PluginStoreItem } from '@/types/plugin-store'; import { InstallStatus } from '@/components/display_card/plugin_store_card'; import TailwindMarkdown from '@/components/tailwind_markdown'; +import PluginManagerController from '@/controllers/plugin_manager'; interface PluginDetailModalProps { isOpen: boolean; @@ -124,12 +126,15 @@ export default function PluginDetailModal ({ const [readmeLoading, setReadmeLoading] = useState(false); const [readmeError, setReadmeError] = useState(false); + // 判断插件来源 + const isNpmSource = plugin?.source === 'npm'; + // 获取 GitHub 仓库信息(需要在 hooks 之前计算) const githubRepo = plugin ? extractGitHubRepo(plugin.homepage) : null; - // 当模态框打开且有 GitHub 链接时,获取 README + // 当模态框打开时,获取 README(npm 或 GitHub) useEffect(() => { - if (!isOpen || !githubRepo) { + if (!isOpen || !plugin) { setReadme(''); setReadmeError(false); return; @@ -139,9 +144,22 @@ export default function PluginDetailModal ({ setReadmeLoading(true); setReadmeError(false); try { - const content = await fetchGitHubReadme(githubRepo.owner, githubRepo.repo); - // 清理 HTML 标签后再设置 - setReadme(cleanReadmeHtml(content)); + if (isNpmSource && plugin.npmPackage) { + // npm 来源:从后端获取 npm 包详情中的 README + const detail = await PluginManagerController.getNpmPluginDetail(plugin.npmPackage); + if (detail?.readme) { + setReadme(cleanReadmeHtml(detail.readme)); + } else { + setReadmeError(true); + } + } else if (githubRepo) { + // GitHub 来源:从 GitHub API 获取 README + const content = await fetchGitHubReadme(githubRepo.owner, githubRepo.repo); + setReadme(cleanReadmeHtml(content)); + } else { + // 无可用的 README 来源 + setReadme(''); + } } catch (error) { console.error('Failed to fetch README:', error); setReadmeError(true); @@ -151,11 +169,11 @@ export default function PluginDetailModal ({ }; loadReadme(); - }, [isOpen, githubRepo?.owner, githubRepo?.repo]); + }, [isOpen, plugin?.id, isNpmSource, plugin?.npmPackage, githubRepo?.owner, githubRepo?.repo]); if (!plugin) return null; - const { name, version, author, description, tags, homepage, downloadUrl, minVersion } = plugin; + const { name, version, author, description, tags, homepage, downloadUrl, minVersion, npmPackage } = plugin; const avatarUrl = getAuthorAvatar(homepage, downloadUrl) || `https://avatar.vercel.sh/${encodeURIComponent(name)}`; return ( @@ -213,6 +231,16 @@ export default function PluginDetailModal ({ v{version} + {isNpmSource && ( + } + > + npm + + )} {tags?.map((tag) => ( 插件 ID: {plugin.id} + {npmPackage && ( +
+ npm 包名: + +
+ )} {downloadUrl && (
下载地址: @@ -301,8 +346,8 @@ export default function PluginDetailModal ({
- {/* GitHub README 显示 */} - {githubRepo && ( + {/* README 显示(支持 npm 和 GitHub) */} + {(githubRepo || isNpmSource) && ( <>

详情

@@ -316,17 +361,19 @@ export default function PluginDetailModal ({

无法加载 README

- + {homepage && ( + + )}
)} {!readmeLoading && !readmeError && readme && ( diff --git a/packages/napcat-webui-frontend/src/pages/dashboard/plugin_store.tsx b/packages/napcat-webui-frontend/src/pages/dashboard/plugin_store.tsx index 93cf9070..8e3f741a 100644 --- a/packages/napcat-webui-frontend/src/pages/dashboard/plugin_store.tsx +++ b/packages/napcat-webui-frontend/src/pages/dashboard/plugin_store.tsx @@ -3,9 +3,11 @@ import { Input } from '@heroui/input'; import { Tab, Tabs } from '@heroui/tabs'; import { Tooltip } from '@heroui/tooltip'; import { Spinner } from '@heroui/spinner'; +import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from '@heroui/modal'; import { useEffect, useMemo, useState, useRef } from 'react'; import toast from 'react-hot-toast'; import { IoMdRefresh, IoMdSearch, IoMdSettings } from 'react-icons/io'; +import { MdOutlineGetApp } from 'react-icons/md'; import clsx from 'clsx'; import { EventSourcePolyfill } from 'event-source-polyfill'; import { useLocalStorage } from '@uidotdev/usehooks'; @@ -82,6 +84,13 @@ export default function PluginStorePage () { const [pendingInstallPlugin, setPendingInstallPlugin] = useState(null); const [selectedDownloadMirror, setSelectedDownloadMirror] = useState(undefined); + // npm 注册表镜像弹窗状态 + const [npmRegistryModalOpen, setNpmRegistryModalOpen] = useState(false); + const [selectedNpmRegistry, setSelectedNpmRegistry] = useState(undefined); + + // npm 直接安装弹窗状态 + const [npmInstallModalOpen, setNpmInstallModalOpen] = useState(false); + // 插件详情弹窗状态 const [detailModalOpen, setDetailModalOpen] = useState(false); const [selectedPlugin, setSelectedPlugin] = useState(null); @@ -179,12 +188,19 @@ export default function PluginStorePage () { }, [categorizedPlugins]); const handleInstall = async (plugin: PluginStoreItem) => { - // 弹窗选择下载镜像 - setPendingInstallPlugin(plugin); - setDownloadMirrorModalOpen(true); + const isNpmSource = plugin.source === 'npm' && plugin.npmPackage; + if (isNpmSource) { + // npm 源 → 选择 npm registry 镜像 + setPendingInstallPlugin(plugin); + setNpmRegistryModalOpen(true); + } else { + // GitHub 源(默认/向后兼容)→ 选择 GitHub 下载镜像 + setPendingInstallPlugin(plugin); + setDownloadMirrorModalOpen(true); + } }; - const installPluginWithSSE = async (pluginId: string, mirror?: string) => { + const installPluginWithSSE = async (pluginId: string, mirror?: string, registry?: string) => { const loadingToast = toast.loading('正在准备安装...'); try { @@ -200,6 +216,9 @@ export default function PluginStorePage () { if (mirror) { params.append('mirror', mirror); } + if (registry) { + params.append('registry', registry); + } const eventSource = new EventSourcePolyfill( `/api/Plugin/Store/Install/SSE?${params.toString()}`, @@ -288,6 +307,74 @@ export default function PluginStorePage () { } }; + const installNpmPackageWithSSE = async (packageName: string, registry?: string) => { + const loadingToast = toast.loading('正在从 npm 安装...'); + + try { + const token = localStorage.getItem(key.token); + if (!token) { + toast.error('未登录,请先登录', { id: loadingToast }); + return; + } + const _token = JSON.parse(token); + + const params = new URLSearchParams({ packageName }); + if (registry) params.append('registry', registry); + + const eventSource = new EventSourcePolyfill( + `/api/Plugin/Npm/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 }); + setInstallProgress(prev => ({ ...prev, show: false })); + eventSource.close(); + } else if (data.success) { + toast.success('从 npm 安装成功!', { id: loadingToast }); + setInstallProgress(prev => ({ ...prev, show: false })); + eventSource.close(); + loadPlugins(); + } else if (data.message) { + if (typeof data.progress === 'number' && data.progress >= 0 && data.progress <= 100) { + setInstallProgress((prev) => ({ + ...prev, + show: true, + message: data.message, + progress: data.progress, + speedStr: data.speedStr || (data.message.includes('下载') ? prev.speedStr : undefined), + eta: data.eta !== undefined ? data.eta : (data.message.includes('下载') ? prev.eta : undefined), + downloadedStr: data.downloadedStr || (data.message.includes('下载') ? prev.downloadedStr : undefined), + totalStr: data.totalStr || (data.message.includes('下载') ? prev.totalStr : undefined), + })); + } else { + toast.loading(data.message, { id: loadingToast }); + } + } + } catch (e) { + console.error('Failed to parse SSE message:', e); + } + }; + + eventSource.onerror = () => { + toast.error('连接中断,安装失败', { id: loadingToast }); + setInstallProgress(prev => ({ ...prev, show: false })); + eventSource.close(); + }; + } catch (error: any) { + toast.error(`安装失败: ${error.message || '未知错误'}`, { id: loadingToast }); + } + }; + const getStoreSourceDisplayName = () => { if (!currentStoreSource) return '默认源'; try { @@ -329,6 +416,18 @@ export default function PluginStorePage () { + + + {/* 顶栏搜索框与列表源 */} @@ -428,7 +527,7 @@ export default function PluginStorePage () { type='raw' /> - {/* 下载镜像选择弹窗 */} + {/* 下载镜像选择弹窗(GitHub 源插件使用) */} { @@ -440,7 +539,7 @@ export default function PluginStorePage () { // 选择后立即开始安装 if (pendingInstallPlugin) { setDownloadMirrorModalOpen(false); - installPluginWithSSE(pendingInstallPlugin.id, mirror); + installPluginWithSSE(pendingInstallPlugin.id, mirror, undefined); setPendingInstallPlugin(null); } }} @@ -448,6 +547,24 @@ export default function PluginStorePage () { type='file' /> + {/* npm Registry 选择弹窗(npm 源插件使用) */} + { + setNpmRegistryModalOpen(false); + setPendingInstallPlugin(null); + }} + onSelect={(registry) => { + setSelectedNpmRegistry(registry); + if (pendingInstallPlugin) { + setNpmRegistryModalOpen(false); + installPluginWithSSE(pendingInstallPlugin.id, undefined, registry); + setPendingInstallPlugin(null); + } + }} + currentRegistry={selectedNpmRegistry} + /> + {/* 插件详情弹窗 */} + {/* npm 直接安装弹窗 */} + setNpmInstallModalOpen(false)} + onInstall={(packageName, registry) => { + setNpmInstallModalOpen(false); + // 使用 SSE 安装 npm 包 + installNpmPackageWithSSE(packageName, registry); + }} + /> + {/* 插件下载进度条全局居中样式 */} {installProgress.show && (
@@ -529,3 +657,249 @@ export default function PluginStorePage () { ); } + +// ============== npm Registry 选择弹窗 ============== + +const NPM_REGISTRIES = [ + { label: '淘宝镜像(推荐)', value: 'https://registry.npmmirror.com', recommended: true }, + { label: 'npm 官方', value: 'https://registry.npmjs.org' }, +]; + +interface NpmRegistrySelectorModalProps { + isOpen: boolean; + onClose: () => void; + onSelect: (registry: string | undefined) => void; + currentRegistry?: string; +} + +function NpmRegistrySelectorModal ({ + isOpen, + onClose, + onSelect, + currentRegistry, +}: NpmRegistrySelectorModalProps) { + const [selected, setSelected] = useState(currentRegistry || NPM_REGISTRIES[0]?.value || ''); + + return ( + + + 选择 npm 镜像源 + +
+ {NPM_REGISTRIES.map((reg) => ( +
setSelected(reg.value)} + > +
+

{reg.label}

+

{reg.value}

+
+ {reg.recommended && ( + 推荐 + )} +
+ ))} +
+
+ + + + +
+
+ ); +} + +// ============== npm 直接安装弹窗 ============== + +interface NpmDirectInstallModalProps { + isOpen: boolean; + onClose: () => void; + onInstall: (packageName: string, registry?: string) => void; +} + +function NpmDirectInstallModal ({ + isOpen, + onClose, + onInstall, +}: NpmDirectInstallModalProps) { + const [packageName, setPackageName] = useState(''); + const [registry, setRegistry] = useState(NPM_REGISTRIES[0]?.value || ''); + const [searchKeyword, setSearchKeyword] = useState(''); + const [searchResults, setSearchResults] = useState([]); + const [searching, setSearching] = useState(false); + const [activeTab, setActiveTab] = useState('search'); + + const handleInstall = () => { + if (!packageName.trim()) { + toast.error('请输入 npm 包名'); + return; + } + onInstall(packageName.trim(), registry); + setPackageName(''); + }; + + const handleSearch = async () => { + const keyword = searchKeyword.trim() || 'napcat-plugin'; + setSearching(true); + try { + const result = await PluginManager.searchNpmPlugins(keyword, registry); + setSearchResults(result.plugins || []); + if (result.plugins.length === 0) { + toast('未找到相关插件', { icon: '🔍' }); + } + } catch (error: any) { + toast.error('搜索失败: ' + (error?.message || '未知错误')); + } finally { + setSearching(false); + } + }; + + return ( + + + 从 npm 安装插件 + +
+ {/* npm 镜像源选择 */} +
+

npm 镜像源

+
+ {NPM_REGISTRIES.map((reg) => ( +
setRegistry(reg.value)} + > + {reg.label} +
+ ))} +
+
+ + {/* 搜索 / 手动输入 切换 */} + setActiveTab(key as string)} + variant='underlined' + color='primary' + > + +
+
+ { if (e.key === 'Enter') handleSearch(); }} + startContent={} + size='sm' + /> + +
+ {/* 搜索结果列表 */} + {searchResults.length > 0 && ( +
+ {searchResults.map((pkg) => ( +
+
+
+ {pkg.name} + v{pkg.version} +
+

+ {pkg.description || '暂无描述'} +

+ {pkg.author && ( +

+ by {pkg.author} +

+ )} +
+ +
+ ))} +
+ )} +
+
+ +
+

+ 输入 npm 包名直接安装插件,适合安装未上架的第三方插件。 +

+ { if (e.key === 'Enter') handleInstall(); }} + /> +
+
+
+
+
+ + + {activeTab === 'manual' && ( + + )} + +
+
+ ); +} diff --git a/packages/napcat-webui-frontend/src/types/plugin-store.ts b/packages/napcat-webui-frontend/src/types/plugin-store.ts index 4debee6b..94a86bc8 100644 --- a/packages/napcat-webui-frontend/src/types/plugin-store.ts +++ b/packages/napcat-webui-frontend/src/types/plugin-store.ts @@ -1,5 +1,8 @@ // 插件商店相关类型定义 +/** 插件来源类型 */ +export type PluginSourceType = 'npm' | 'github'; + export interface PluginStoreItem { id: string; // 插件唯一标识 name: string; // 插件名称 @@ -7,9 +10,13 @@ export interface PluginStoreItem { description: string; // 插件描述 author: string; // 作者 homepage?: string; // 主页链接 - downloadUrl: string; // 下载地址 + downloadUrl: string; // 下载地址(GitHub 模式兼容) tags?: string[]; // 标签 minVersion?: string; // 最低版本要求 + /** 插件来源类型,默认 'github' 保持向后兼容 */ + source?: PluginSourceType; + /** npm 包名(当 source 为 'npm' 时使用) */ + npmPackage?: string; } export interface PluginStoreList {