feat: 插件系统引入npm

This commit is contained in:
时瑾
2026-02-12 15:24:58 +08:00
parent 82d0c51716
commit 5edafeed3e
9 changed files with 1388 additions and 117 deletions

View File

@@ -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

View File

@@ -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 },
);
}
// ==================== 插件配置 ====================
/**

View File

@@ -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
// 当模态框打开时,获取 READMEnpm 或 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 && (

View File

@@ -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>
);
}

View File

@@ -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 {