mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-02-13 00:10:27 +00:00
feat: 插件系统引入npm
This commit is contained in:
314
packages/napcat-common/src/npm-registry.ts
Normal file
314
packages/napcat-common/src/npm-registry.ts
Normal file
@@ -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<string, string>;
|
||||
versions: Record<string, NpmPackageVersionInfo>;
|
||||
time?: Record<string, string>;
|
||||
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<string, MetadataCache> = 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<NpmPackageMetadata> {
|
||||
// 检查缓存
|
||||
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<NpmPackageVersionInfo> {
|
||||
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<NpmPackageVersionInfo> {
|
||||
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<void> {
|
||||
// 如果指定了 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();
|
||||
}
|
||||
@@ -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<void>
|
||||
console.log('[extractPlugin] Extracted files:', files);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解压 npm tarball (.tgz) 到指定目录
|
||||
* npm tarball 解压后通常有一个 "package/" 前缀目录,需要去掉
|
||||
*/
|
||||
async function extractNpmTarball (tgzPath: string, pluginId: string): Promise<void> {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -185,6 +185,19 @@ const PluginStoreCard: React.FC<PluginStoreCardProps> = ({
|
||||
v{version}
|
||||
</Chip>
|
||||
|
||||
{/* 来源标识 */}
|
||||
{data.source === 'npm' && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='flat'
|
||||
color='danger'
|
||||
className='h-5 text-xs font-semibold px-0.5'
|
||||
classNames={{ content: 'px-1' }}
|
||||
>
|
||||
npm
|
||||
</Chip>
|
||||
)}
|
||||
|
||||
{/* Tags with proper truncation and hover */}
|
||||
{tags?.slice(0, 2).map((tag) => (
|
||||
<Chip
|
||||
|
||||
@@ -164,16 +164,49 @@ export default class PluginManager {
|
||||
/**
|
||||
* 从商店安装插件
|
||||
* @param id 插件 ID
|
||||
* @param mirror 镜像源
|
||||
* @param mirror 镜像源(GitHub 模式)
|
||||
* @param registry npm 镜像源(npm 模式)
|
||||
*/
|
||||
public static async installPluginFromStore (id: string, mirror?: string): Promise<void> {
|
||||
public static async installPluginFromStore (id: string, mirror?: string, registry?: string): Promise<void> {
|
||||
await serverRequest.post<ServerResponse<void>>(
|
||||
'/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<string, string> = { keyword, from: String(from), size: String(size) };
|
||||
if (registry) params['registry'] = registry;
|
||||
const { data } = await serverRequest.get<ServerResponse<{ total: number; plugins: PluginStoreItem[]; }>>('/Plugin/Npm/Search', { params });
|
||||
return data.data;
|
||||
}
|
||||
|
||||
/** 获取 npm 包详情 */
|
||||
public static async getNpmPluginDetail (packageName: string, registry?: string): Promise<any> {
|
||||
const params: Record<string, string> = {};
|
||||
if (registry) params['registry'] = registry;
|
||||
const { data } = await serverRequest.get<ServerResponse<any>>(`/Plugin/Npm/Detail/${encodeURIComponent(packageName)}`, { params });
|
||||
return data.data;
|
||||
}
|
||||
|
||||
/** 从 npm 直接安装插件 */
|
||||
public static async installPluginFromNpm (packageName: string, version?: string, registry?: string): Promise<void> {
|
||||
await serverRequest.post<ServerResponse<void>>(
|
||||
'/Plugin/Npm/Install',
|
||||
{ packageName, version, registry },
|
||||
{ timeout: 300000 },
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== 插件配置 ====================
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 ({
|
||||
<Chip size='sm' color='primary' variant='flat'>
|
||||
v{version}
|
||||
</Chip>
|
||||
{isNpmSource && (
|
||||
<Chip
|
||||
size='sm'
|
||||
color='danger'
|
||||
variant='flat'
|
||||
startContent={<FaNpm size={14} />}
|
||||
>
|
||||
npm
|
||||
</Chip>
|
||||
)}
|
||||
{tags?.map((tag) => (
|
||||
<Chip
|
||||
key={tag}
|
||||
@@ -281,6 +309,23 @@ export default function PluginDetailModal ({
|
||||
<span className='text-default-500'>插件 ID:</span>
|
||||
<span className='font-mono text-xs text-default-900'>{plugin.id}</span>
|
||||
</div>
|
||||
{npmPackage && (
|
||||
<div className='flex justify-between items-center'>
|
||||
<span className='text-default-500'>npm 包名:</span>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='flat'
|
||||
color='danger'
|
||||
as='a'
|
||||
href={`https://www.npmjs.com/package/${npmPackage}`}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
startContent={<FaNpm size={14} />}
|
||||
>
|
||||
{npmPackage}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{downloadUrl && (
|
||||
<div className='flex justify-between items-center'>
|
||||
<span className='text-default-500'>下载地址:</span>
|
||||
@@ -301,8 +346,8 @@ export default function PluginDetailModal ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* GitHub README 显示 */}
|
||||
{githubRepo && (
|
||||
{/* README 显示(支持 npm 和 GitHub) */}
|
||||
{(githubRepo || isNpmSource) && (
|
||||
<>
|
||||
<div className='mt-2'>
|
||||
<h3 className='text-sm font-semibold text-default-700 mb-3'>详情</h3>
|
||||
@@ -316,17 +361,19 @@ export default function PluginDetailModal ({
|
||||
<p className='text-sm text-default-500 mb-3'>
|
||||
无法加载 README
|
||||
</p>
|
||||
<Button
|
||||
color='primary'
|
||||
variant='flat'
|
||||
as='a'
|
||||
href={homepage}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
startContent={<IoMdOpen />}
|
||||
>
|
||||
在 GitHub 查看
|
||||
</Button>
|
||||
{homepage && (
|
||||
<Button
|
||||
color='primary'
|
||||
variant='flat'
|
||||
as='a'
|
||||
href={homepage}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
startContent={<IoMdOpen />}
|
||||
>
|
||||
{isNpmSource ? '在 npm 查看' : '在 GitHub 查看'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!readmeLoading && !readmeError && readme && (
|
||||
|
||||
@@ -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<PluginStoreItem | null>(null);
|
||||
const [selectedDownloadMirror, setSelectedDownloadMirror] = useState<string | undefined>(undefined);
|
||||
|
||||
// npm 注册表镜像弹窗状态
|
||||
const [npmRegistryModalOpen, setNpmRegistryModalOpen] = useState(false);
|
||||
const [selectedNpmRegistry, setSelectedNpmRegistry] = useState<string | undefined>(undefined);
|
||||
|
||||
// npm 直接安装弹窗状态
|
||||
const [npmInstallModalOpen, setNpmInstallModalOpen] = useState(false);
|
||||
|
||||
// 插件详情弹窗状态
|
||||
const [detailModalOpen, setDetailModalOpen] = useState(false);
|
||||
const [selectedPlugin, setSelectedPlugin] = useState<PluginStoreItem | null>(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 () {
|
||||
<IoMdRefresh size={20} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content='从 npm 包名安装插件'>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='flat'
|
||||
className='bg-default-100/50 hover:bg-default-200/50 text-default-700 backdrop-blur-md'
|
||||
radius='full'
|
||||
startContent={<MdOutlineGetApp size={18} />}
|
||||
onPress={() => setNpmInstallModalOpen(true)}
|
||||
>
|
||||
npm 安装
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* 顶栏搜索框与列表源 */}
|
||||
@@ -428,7 +527,7 @@ export default function PluginStorePage () {
|
||||
type='raw'
|
||||
/>
|
||||
|
||||
{/* 下载镜像选择弹窗 */}
|
||||
{/* 下载镜像选择弹窗(GitHub 源插件使用) */}
|
||||
<MirrorSelectorModal
|
||||
isOpen={downloadMirrorModalOpen}
|
||||
onClose={() => {
|
||||
@@ -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 源插件使用) */}
|
||||
<NpmRegistrySelectorModal
|
||||
isOpen={npmRegistryModalOpen}
|
||||
onClose={() => {
|
||||
setNpmRegistryModalOpen(false);
|
||||
setPendingInstallPlugin(null);
|
||||
}}
|
||||
onSelect={(registry) => {
|
||||
setSelectedNpmRegistry(registry);
|
||||
if (pendingInstallPlugin) {
|
||||
setNpmRegistryModalOpen(false);
|
||||
installPluginWithSSE(pendingInstallPlugin.id, undefined, registry);
|
||||
setPendingInstallPlugin(null);
|
||||
}
|
||||
}}
|
||||
currentRegistry={selectedNpmRegistry}
|
||||
/>
|
||||
|
||||
{/* 插件详情弹窗 */}
|
||||
<PluginDetailModal
|
||||
isOpen={detailModalOpen}
|
||||
@@ -470,6 +587,17 @@ export default function PluginStorePage () {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* npm 直接安装弹窗 */}
|
||||
<NpmDirectInstallModal
|
||||
isOpen={npmInstallModalOpen}
|
||||
onClose={() => setNpmInstallModalOpen(false)}
|
||||
onInstall={(packageName, registry) => {
|
||||
setNpmInstallModalOpen(false);
|
||||
// 使用 SSE 安装 npm 包
|
||||
installNpmPackageWithSSE(packageName, registry);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 插件下载进度条全局居中样式 */}
|
||||
{installProgress.show && (
|
||||
<div className='fixed inset-0 flex items-center justify-center z-[9999] animate-in fade-in duration-300'>
|
||||
@@ -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<string>(currentRegistry || NPM_REGISTRIES[0]?.value || '');
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size='md'
|
||||
classNames={{
|
||||
backdrop: 'z-[200]',
|
||||
wrapper: 'z-[200]',
|
||||
}}
|
||||
>
|
||||
<ModalContent>
|
||||
<ModalHeader>选择 npm 镜像源</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className='flex flex-col gap-2'>
|
||||
{NPM_REGISTRIES.map((reg) => (
|
||||
<div
|
||||
key={reg.value}
|
||||
className={clsx(
|
||||
'flex items-center justify-between p-3 rounded-lg cursor-pointer transition-all',
|
||||
'bg-content1 hover:bg-content2 border-2',
|
||||
selected === reg.value ? 'border-primary' : 'border-transparent',
|
||||
)}
|
||||
onClick={() => setSelected(reg.value)}
|
||||
>
|
||||
<div>
|
||||
<p className='font-medium'>{reg.label}</p>
|
||||
<p className='text-xs text-default-500'>{reg.value}</p>
|
||||
</div>
|
||||
{reg.recommended && (
|
||||
<span className='text-xs bg-primary/10 text-primary px-2 py-0.5 rounded-full'>推荐</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant='light' onPress={onClose}>取消</Button>
|
||||
<Button color='primary' onPress={() => { onSelect(selected); onClose(); }}>
|
||||
确认并安装
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ============== 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<PluginStoreItem[]>([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<string>('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 (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size='2xl'
|
||||
scrollBehavior='inside'
|
||||
classNames={{
|
||||
backdrop: 'z-[200]',
|
||||
wrapper: 'z-[200]',
|
||||
}}
|
||||
>
|
||||
<ModalContent>
|
||||
<ModalHeader>从 npm 安装插件</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className='flex flex-col gap-4'>
|
||||
{/* npm 镜像源选择 */}
|
||||
<div className='flex flex-col gap-2'>
|
||||
<p className='text-sm font-medium'>npm 镜像源</p>
|
||||
<div className='flex gap-2'>
|
||||
{NPM_REGISTRIES.map((reg) => (
|
||||
<div
|
||||
key={reg.value}
|
||||
className={clsx(
|
||||
'flex-1 flex items-center justify-center p-2 rounded-lg cursor-pointer transition-all text-sm',
|
||||
'bg-content1 hover:bg-content2 border-2',
|
||||
registry === reg.value ? 'border-primary' : 'border-transparent',
|
||||
)}
|
||||
onClick={() => setRegistry(reg.value)}
|
||||
>
|
||||
<span>{reg.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 搜索 / 手动输入 切换 */}
|
||||
<Tabs
|
||||
selectedKey={activeTab}
|
||||
onSelectionChange={(key) => setActiveTab(key as string)}
|
||||
variant='underlined'
|
||||
color='primary'
|
||||
>
|
||||
<Tab key='search' title='搜索插件'>
|
||||
<div className='flex flex-col gap-3'>
|
||||
<div className='flex gap-2'>
|
||||
<Input
|
||||
placeholder='搜索 napcat 插件...'
|
||||
value={searchKeyword}
|
||||
onValueChange={setSearchKeyword}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleSearch(); }}
|
||||
startContent={<IoMdSearch className='text-default-400' />}
|
||||
size='sm'
|
||||
/>
|
||||
<Button
|
||||
color='primary'
|
||||
size='sm'
|
||||
onPress={handleSearch}
|
||||
isLoading={searching}
|
||||
className='flex-shrink-0'
|
||||
>
|
||||
搜索
|
||||
</Button>
|
||||
</div>
|
||||
{/* 搜索结果列表 */}
|
||||
{searchResults.length > 0 && (
|
||||
<div className='flex flex-col gap-2 max-h-64 overflow-y-auto'>
|
||||
{searchResults.map((pkg) => (
|
||||
<div
|
||||
key={pkg.id}
|
||||
className='flex items-center justify-between p-3 rounded-lg bg-content1 hover:bg-content2 transition-all'
|
||||
>
|
||||
<div className='flex-1 min-w-0'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='font-medium text-sm truncate'>{pkg.name}</span>
|
||||
<span className='text-xs text-default-400'>v{pkg.version}</span>
|
||||
</div>
|
||||
<p className='text-xs text-default-500 mt-1 truncate'>
|
||||
{pkg.description || '暂无描述'}
|
||||
</p>
|
||||
{pkg.author && (
|
||||
<p className='text-xs text-default-400 mt-0.5'>
|
||||
by {pkg.author}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size='sm'
|
||||
color='primary'
|
||||
variant='flat'
|
||||
onPress={() => {
|
||||
onInstall(pkg.npmPackage || pkg.id, registry);
|
||||
}}
|
||||
className='flex-shrink-0 ml-2'
|
||||
>
|
||||
安装
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab key='manual' title='手动输入'>
|
||||
<div className='flex flex-col gap-3'>
|
||||
<p className='text-sm text-default-500'>
|
||||
输入 npm 包名直接安装插件,适合安装未上架的第三方插件。
|
||||
</p>
|
||||
<Input
|
||||
label='npm 包名'
|
||||
placeholder='例如: napcat-plugin-example'
|
||||
value={packageName}
|
||||
onValueChange={setPackageName}
|
||||
description='输入完整的 npm 包名'
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleInstall(); }}
|
||||
/>
|
||||
</div>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant='light' onPress={onClose}>取消</Button>
|
||||
{activeTab === 'manual' && (
|
||||
<Button color='primary' onPress={handleInstall} isDisabled={!packageName.trim()}>
|
||||
安装
|
||||
</Button>
|
||||
)}
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user