diff --git a/packages/napcat-onebot/network/plugin/types.ts b/packages/napcat-onebot/network/plugin/types.ts index 0465bffe..900b299d 100644 --- a/packages/napcat-onebot/network/plugin/types.ts +++ b/packages/napcat-onebot/network/plugin/types.ts @@ -15,6 +15,7 @@ export interface PluginPackageJson { author?: string; homepage?: string; repository?: string | { type: string; url: string; }; + icon?: string; // 插件图标文件路径(相对于插件目录),如 "icon.png" } // ==================== 插件配置 Schema ==================== diff --git a/packages/napcat-webui-backend/src/api/Plugin.ts b/packages/napcat-webui-backend/src/api/Plugin.ts index fd9e3a3e..6904624c 100644 --- a/packages/napcat-webui-backend/src/api/Plugin.ts +++ b/packages/napcat-webui-backend/src/api/Plugin.ts @@ -8,6 +8,49 @@ import path from 'path'; import fs from 'fs'; import compressing from 'compressing'; +/** + * 获取插件图标 URL + * 优先使用 package.json 中的 icon 字段,否则检查缓存的图标文件 + */ +function getPluginIconUrl (pluginId: string, pluginPath: string, iconField?: string): string | undefined { + // 1. 检查 package.json 中指定的 icon 文件 + if (iconField) { + const iconPath = path.join(pluginPath, iconField); + if (fs.existsSync(iconPath)) { + return `/api/Plugin/Icon/${encodeURIComponent(pluginId)}`; + } + } + + // 2. 检查 config 目录中缓存的图标 (固定 icon.png) + const cachedIcon = path.join(webUiPathWrapper.configPath, 'plugins', pluginId, 'icon.png'); + if (fs.existsSync(cachedIcon)) { + return `/api/Plugin/Icon/${encodeURIComponent(pluginId)}`; + } + + return undefined; +} + +/** + * 查找插件图标文件的实际路径 + */ +function findPluginIconPath (pluginId: string, pluginPath: string, iconField?: string): string | undefined { + // 1. 优先使用 package.json 中指定的 icon + if (iconField) { + const iconPath = path.join(pluginPath, iconField); + if (fs.existsSync(iconPath)) { + return iconPath; + } + } + + // 2. 检查 config 目录中缓存的图标 (固定 icon.png) + const cachedIcon = path.join(webUiPathWrapper.configPath, 'plugins', pluginId, 'icon.png'); + if (fs.existsSync(cachedIcon)) { + return cachedIcon; + } + + return undefined; +} + // Helper to get the plugin manager adapter const getPluginManager = (): OB11PluginMangerAdapter | null => { const ob11 = WebUiDataRuntime.getOneBotContext() as NapCatOneBot11Adapter; @@ -77,6 +120,7 @@ export const GetPluginListHandler: RequestHandler = async (_req, res) => { hasPages: boolean; homepage?: string; repository?: string; + icon?: string; }> = new Array(); // 收集所有插件的扩展页面 @@ -117,7 +161,8 @@ export const GetPluginListHandler: RequestHandler = async (_req, res) => { homepage: p.packageJson?.homepage, repository: typeof p.packageJson?.repository === 'string' ? p.packageJson.repository - : p.packageJson?.repository?.url + : p.packageJson?.repository?.url, + icon: getPluginIconUrl(p.id, p.pluginPath, p.packageJson?.icon), }); // 收集插件的扩展页面 @@ -600,3 +645,24 @@ export const ImportLocalPluginHandler: RequestHandler = async (req, res) => { return sendError(res, 'Failed to import plugin: ' + e.message); } }; + +/** + * 获取插件图标 + */ +export const GetPluginIconHandler: RequestHandler = async (req, res) => { + const pluginId = req.params['pluginId']; + if (!pluginId) return sendError(res, 'Plugin ID is required'); + + const pluginManager = getPluginManager(); + if (!pluginManager) return sendError(res, 'Plugin Manager not found'); + + const plugin = pluginManager.getPluginInfo(pluginId); + if (!plugin) return sendError(res, 'Plugin not found'); + + const iconPath = findPluginIconPath(pluginId, plugin.pluginPath, plugin.packageJson?.icon); + if (!iconPath) { + return res.status(404).json({ code: -1, message: 'Icon not found' }); + } + + return res.sendFile(iconPath); +}; diff --git a/packages/napcat-webui-backend/src/api/PluginStore.ts b/packages/napcat-webui-backend/src/api/PluginStore.ts index 974f9238..db0b6c11 100644 --- a/packages/napcat-webui-backend/src/api/PluginStore.ts +++ b/packages/napcat-webui-backend/src/api/PluginStore.ts @@ -287,6 +287,95 @@ async function extractPlugin (zipPath: string, pluginId: string): Promise console.log('[extractPlugin] Extracted files:', files); } +/** + * 安装后尝试缓存插件图标 + * 如果插件 package.json 没有 icon 字段,则尝试从 GitHub 头像获取并缓存到 config 目录 + */ +async function cachePluginIcon (pluginId: string, storePlugin: PluginStoreList['plugins'][0]): Promise { + const PLUGINS_DIR = getPluginsDir(); + const pluginDir = path.join(PLUGINS_DIR, pluginId); + const configDir = path.join(webUiPathWrapper.configPath, 'plugins', pluginId); + + // 检查 package.json 是否已有 icon 字段 + const packageJsonPath = path.join(pluginDir, 'package.json'); + if (fs.existsSync(packageJsonPath)) { + try { + const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); + if (pkg.icon) { + const iconPath = path.join(pluginDir, pkg.icon); + if (fs.existsSync(iconPath)) { + return; // 已有 icon,无需缓存 + } + } + } catch { + // 忽略解析错误 + } + } + + // 检查是否已有缓存的图标 (固定 icon.png) + if (fs.existsSync(path.join(configDir, 'icon.png'))) { + return; // 已有缓存图标 + } + + // 尝试从 GitHub 获取头像 + let avatarUrl: string | undefined; + + // 从 downloadUrl 提取 GitHub 用户名 + if (storePlugin.downloadUrl) { + try { + const url = new URL(storePlugin.downloadUrl); + if (url.hostname === 'github.com' || url.hostname === 'www.github.com') { + const parts = url.pathname.split('/').filter(Boolean); + if (parts.length >= 1) { + avatarUrl = `https://github.com/${parts[0]}.png?size=128`; + } + } + } catch { + // 忽略 + } + } + + // 从 homepage 提取 + if (!avatarUrl && storePlugin.homepage) { + try { + const url = new URL(storePlugin.homepage); + if (url.hostname === 'github.com' || url.hostname === 'www.github.com') { + const parts = url.pathname.split('/').filter(Boolean); + if (parts.length >= 1) { + avatarUrl = `https://github.com/${parts[0]}.png?size=128`; + } + } + } catch { + // 忽略 + } + } + + if (!avatarUrl) return; + + try { + // 确保 config 目录存在 + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true }); + } + + const response = await fetch(avatarUrl, { + headers: { 'User-Agent': 'NapCat-WebUI' }, + signal: AbortSignal.timeout(15000), + redirect: 'follow', + }); + + if (!response.ok || !response.body) return; + + const iconPath = path.join(configDir, 'icon.png'); + const fileStream = createWriteStream(iconPath); + await pipeline(response.body as any, fileStream); + + console.log(`[cachePluginIcon] Cached icon for ${pluginId} at ${iconPath}`); + } catch (e: any) { + console.warn(`[cachePluginIcon] Failed to cache icon for ${pluginId}:`, e.message); + } +} + /** * 获取插件商店列表 */ @@ -374,6 +463,13 @@ export const InstallPluginFromStoreHandler: RequestHandler = async (req, res) => } } + // 安装后尝试缓存插件图标(如果 package.json 没有 icon 字段),失败可跳过 + try { + await cachePluginIcon(id, plugin); + } catch (e: any) { + console.warn(`[InstallPlugin] Failed to cache icon for ${id}, skipping:`, e.message); + } + return sendSuccess(res, { message: 'Plugin installed successfully', plugin, @@ -497,6 +593,12 @@ export const InstallPluginFromStoreSSEHandler: RequestHandler = async (req, res) } sendProgress('安装成功!', 100); + + // 安装后尝试缓存插件图标(如果 package.json 没有 icon 字段) + cachePluginIcon(id, plugin).catch(e => { + console.warn(`[cachePluginIcon] Failed to cache icon for ${id}:`, e.message); + }); + res.write(`data: ${JSON.stringify({ success: true, message: 'Plugin installed successfully', diff --git a/packages/napcat-webui-backend/src/router/Plugin.ts b/packages/napcat-webui-backend/src/router/Plugin.ts index bb27847f..8665fde0 100644 --- a/packages/napcat-webui-backend/src/router/Plugin.ts +++ b/packages/napcat-webui-backend/src/router/Plugin.ts @@ -12,7 +12,8 @@ import { RegisterPluginManagerHandler, PluginConfigSSEHandler, PluginConfigChangeHandler, - ImportLocalPluginHandler + ImportLocalPluginHandler, + GetPluginIconHandler } from '@/napcat-webui-backend/src/api/Plugin'; import { GetPluginStoreListHandler, @@ -68,6 +69,7 @@ router.get('/Config/SSE', PluginConfigSSEHandler); router.post('/Config/Change', PluginConfigChangeHandler); router.post('/RegisterManager', RegisterPluginManagerHandler); router.post('/Import', upload.single('plugin'), ImportLocalPluginHandler); +router.get('/Icon/:pluginId', GetPluginIconHandler); // 插件商店相关路由 router.get('/Store/List', GetPluginStoreListHandler); diff --git a/packages/napcat-webui-frontend/src/components/display_card/plugin_card.tsx b/packages/napcat-webui-frontend/src/components/display_card/plugin_card.tsx index 4982d712..40fccf02 100644 --- a/packages/napcat-webui-frontend/src/components/display_card/plugin_card.tsx +++ b/packages/napcat-webui-frontend/src/components/display_card/plugin_card.tsx @@ -11,6 +11,7 @@ import { useState } from 'react'; import key from '@/const/key'; import { PluginItem } from '@/controllers/plugin_manager'; +import { getPluginIconUrl } from '@/utils/plugin_icon'; /** 提取作者头像 URL */ function getAuthorAvatar (homepage?: string, repository?: string): string | undefined { @@ -66,15 +67,15 @@ const PluginDisplayCard: React.FC = ({ onConfig, hasConfig = false, }) => { - const { name, version, author, description, status, homepage, repository } = data; + const { name, version, author, description, status, homepage, repository, icon } = data; const isEnabled = status === 'active'; const [processing, setProcessing] = useState(false); const [isExpanded, setIsExpanded] = useState(false); const [backgroundImage] = useLocalStorage(key.backgroundImage, ''); const hasBackground = !!backgroundImage; - // 综合尝试提取头像,最后兜底使用 Vercel 风格头像 - const avatarUrl = getAuthorAvatar(homepage, repository) || `https://avatar.vercel.sh/${encodeURIComponent(name)}`; + // 优先使用后端返回的 icon URL(需要携带 token),否则尝试提取作者头像,最后兜底 Vercel 风格头像 + const avatarUrl = getPluginIconUrl(icon) || getAuthorAvatar(homepage, repository) || `https://avatar.vercel.sh/${encodeURIComponent(name)}`; const handleToggle = () => { setProcessing(true); diff --git a/packages/napcat-webui-frontend/src/controllers/plugin_manager.ts b/packages/napcat-webui-frontend/src/controllers/plugin_manager.ts index cb015d54..36d948cc 100644 --- a/packages/napcat-webui-frontend/src/controllers/plugin_manager.ts +++ b/packages/napcat-webui-frontend/src/controllers/plugin_manager.ts @@ -26,6 +26,8 @@ export interface PluginItem { homepage?: string; /** 仓库链接 */ repository?: string; + /** 插件图标 URL(由后端返回) */ + icon?: string; } /** 扩展页面信息 */ 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..4eadfb5c 100644 --- a/packages/napcat-webui-frontend/src/pages/dashboard/plugin_store.tsx +++ b/packages/napcat-webui-frontend/src/pages/dashboard/plugin_store.tsx @@ -3,7 +3,8 @@ import { Input } from '@heroui/input'; import { Tab, Tabs } from '@heroui/tabs'; import { Tooltip } from '@heroui/tooltip'; import { Spinner } from '@heroui/spinner'; -import { useEffect, useMemo, useState, useRef } from 'react'; +import { Pagination } from '@heroui/pagination'; +import { useEffect, useMemo, useState, useRef, useCallback } from 'react'; import toast from 'react-hot-toast'; import { IoMdRefresh, IoMdSearch, IoMdSettings } from 'react-icons/io'; import clsx from 'clsx'; @@ -19,6 +20,16 @@ import { PluginStoreItem } from '@/types/plugin-store'; import useDialog from '@/hooks/use-dialog'; import key from '@/const/key'; +/** Fisher-Yates 洗牌算法,返回新数组 */ +function shuffleArray (arr: T[]): T[] { + const shuffled = [...arr]; + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; + } + return shuffled; +} + interface EmptySectionProps { isEmpty: boolean; } @@ -86,6 +97,10 @@ export default function PluginStorePage () { const [detailModalOpen, setDetailModalOpen] = useState(false); const [selectedPlugin, setSelectedPlugin] = useState(null); + // 分页状态 + const ITEMS_PER_PAGE = 20; + const [currentPage, setCurrentPage] = useState(1); + const loadPlugins = async (forceRefresh: boolean = false) => { setLoading(true); try { @@ -145,6 +160,7 @@ export default function PluginStorePage () { tools: filtered.filter(p => p.tags?.includes('工具')), entertainment: filtered.filter(p => p.tags?.includes('娱乐')), other: filtered.filter(p => !p.tags?.some(t => ['官方', '工具', '娱乐'].includes(t))), + random: shuffleArray(filtered), }; return categories; @@ -175,9 +191,30 @@ export default function PluginStorePage () { { key: 'tools', title: '工具', count: categorizedPlugins.tools?.length || 0 }, { key: 'entertainment', title: '娱乐', count: categorizedPlugins.entertainment?.length || 0 }, { key: 'other', title: '其它', count: categorizedPlugins.other?.length || 0 }, + { key: 'random', title: '随机', count: categorizedPlugins.random?.length || 0 }, ]; }, [categorizedPlugins]); + // 当前分类的总数和分页数据 + const currentCategoryPlugins = useMemo(() => categorizedPlugins[activeTab] || [], [categorizedPlugins, activeTab]); + const totalPages = useMemo(() => Math.max(1, Math.ceil(currentCategoryPlugins.length / ITEMS_PER_PAGE)), [currentCategoryPlugins.length]); + const paginatedPlugins = useMemo(() => { + const start = (currentPage - 1) * ITEMS_PER_PAGE; + return currentCategoryPlugins.slice(start, start + ITEMS_PER_PAGE); + }, [currentCategoryPlugins, currentPage]); + + // 切换分类或搜索时重置页码 + const handleTabChange = useCallback((key: string) => { + setActiveTab(key); + setCurrentPage(1); + }, []); + + // 搜索变化时重置页码 + const handleSearchChange = useCallback((value: string) => { + setSearchQuery(value); + setCurrentPage(1); + }, []); + const handleInstall = async (plugin: PluginStoreItem) => { // 弹窗选择下载镜像 setPendingInstallPlugin(plugin); @@ -338,7 +375,7 @@ export default function PluginStorePage () { placeholder='搜索(Ctrl+F)...' startContent={} value={searchQuery} - onValueChange={setSearchQuery} + onValueChange={handleSearchChange} className='max-w-xs w-full' size='sm' isClearable @@ -370,7 +407,7 @@ export default function PluginStorePage () { aria-label='Plugin Store Categories' className='max-w-full' selectedKey={activeTab} - onSelectionChange={(key) => setActiveTab(String(key))} + onSelectionChange={(key) => handleTabChange(String(key))} classNames={{ tabList: 'bg-white/40 dark:bg-black/20 backdrop-blur-md', cursor: 'bg-white/80 dark:bg-white/10 backdrop-blur-md shadow-sm', @@ -395,9 +432,9 @@ export default function PluginStorePage () { )} - +
- {categorizedPlugins[activeTab]?.map((plugin) => { + {paginatedPlugins.map((plugin) => { const installInfo = getPluginInstallInfo(plugin); return ( + + {/* 分页控件 */} + {totalPages > 1 && ( +
+ +
+ )}
diff --git a/packages/napcat-webui-frontend/src/utils/plugin_icon.ts b/packages/napcat-webui-frontend/src/utils/plugin_icon.ts new file mode 100644 index 00000000..3bfeccad --- /dev/null +++ b/packages/napcat-webui-frontend/src/utils/plugin_icon.ts @@ -0,0 +1,20 @@ +import key from '@/const/key'; + +/** + * 将后端返回的插件 icon 路径拼接上 webui_token 查询参数 + * 后端 /api/Plugin/Icon/:pluginId 需要鉴权,img src 无法携带 Authorization header, + * 所以通过 query 参数传递 token + */ +export function getPluginIconUrl (iconPath?: string): string | undefined { + if (!iconPath) return undefined; + try { + const raw = localStorage.getItem(key.token); + if (!raw) return iconPath; + const token = JSON.parse(raw); + const url = new URL(iconPath, window.location.origin); + url.searchParams.set('webui_token', token); + return url.pathname + url.search; + } catch { + return iconPath; + } +}