Add plugin icon support and caching

Introduce support for plugin icons across backend and frontend. Updates include:

- napcat-onebot: add optional `icon` field to PluginPackageJson.
- Backend (api/Plugin, PluginStore, router): add handlers/utilities to locate and serve plugin icons (`GetPluginIconHandler`, getPluginIconUrl, findPluginIconPath) and wire the route `/api/Plugin/Icon/:pluginId`.
- Cache logic: implement `cachePluginIcon` to fetch GitHub user avatars and store as `data/icon.png` when package.json lacks an icon; invoked after plugin install (regular and SSE flows).
- Frontend: add `icon` to PluginItem, prefer backend-provided icon URL in plugin card (via new getPluginIconUrl util that appends webui_token query param), and add the util to handle token-based image requests.
- Plugin store UI: add a Random category (shuffled), client-side pagination, and reset page on tab/search changes.

These changes let the UI display plugin icons (falling back to author/avatar or Vercel avatars) and cache icons for better UX, while handling auth by passing the token as a query parameter for img src requests.
This commit is contained in:
手瓜一十雪
2026-02-20 23:32:57 +08:00
parent 1b73d68cbf
commit 48ffd5597a
8 changed files with 259 additions and 10 deletions

View File

@@ -15,6 +15,7 @@ export interface PluginPackageJson {
author?: string; author?: string;
homepage?: string; homepage?: string;
repository?: string | { type: string; url: string; }; repository?: string | { type: string; url: string; };
icon?: string; // 插件图标文件路径(相对于插件目录),如 "icon.png"
} }
// ==================== 插件配置 Schema ==================== // ==================== 插件配置 Schema ====================

View File

@@ -8,6 +8,49 @@ import path from 'path';
import fs from 'fs'; import fs from 'fs';
import compressing from 'compressing'; 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 // Helper to get the plugin manager adapter
const getPluginManager = (): OB11PluginMangerAdapter | null => { const getPluginManager = (): OB11PluginMangerAdapter | null => {
const ob11 = WebUiDataRuntime.getOneBotContext() as NapCatOneBot11Adapter; const ob11 = WebUiDataRuntime.getOneBotContext() as NapCatOneBot11Adapter;
@@ -77,6 +120,7 @@ export const GetPluginListHandler: RequestHandler = async (_req, res) => {
hasPages: boolean; hasPages: boolean;
homepage?: string; homepage?: string;
repository?: string; repository?: string;
icon?: string;
}> = new Array(); }> = new Array();
// 收集所有插件的扩展页面 // 收集所有插件的扩展页面
@@ -117,7 +161,8 @@ export const GetPluginListHandler: RequestHandler = async (_req, res) => {
homepage: p.packageJson?.homepage, homepage: p.packageJson?.homepage,
repository: typeof p.packageJson?.repository === 'string' repository: typeof p.packageJson?.repository === 'string'
? p.packageJson.repository ? 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); 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);
};

View File

@@ -287,6 +287,95 @@ async function extractPlugin (zipPath: string, pluginId: string): Promise<void>
console.log('[extractPlugin] Extracted files:', files); console.log('[extractPlugin] Extracted files:', files);
} }
/**
* 安装后尝试缓存插件图标
* 如果插件 package.json 没有 icon 字段,则尝试从 GitHub 头像获取并缓存到 config 目录
*/
async function cachePluginIcon (pluginId: string, storePlugin: PluginStoreList['plugins'][0]): Promise<void> {
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, { return sendSuccess(res, {
message: 'Plugin installed successfully', message: 'Plugin installed successfully',
plugin, plugin,
@@ -497,6 +593,12 @@ export const InstallPluginFromStoreSSEHandler: RequestHandler = async (req, res)
} }
sendProgress('安装成功!', 100); 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({ res.write(`data: ${JSON.stringify({
success: true, success: true,
message: 'Plugin installed successfully', message: 'Plugin installed successfully',

View File

@@ -12,7 +12,8 @@ import {
RegisterPluginManagerHandler, RegisterPluginManagerHandler,
PluginConfigSSEHandler, PluginConfigSSEHandler,
PluginConfigChangeHandler, PluginConfigChangeHandler,
ImportLocalPluginHandler ImportLocalPluginHandler,
GetPluginIconHandler
} from '@/napcat-webui-backend/src/api/Plugin'; } from '@/napcat-webui-backend/src/api/Plugin';
import { import {
GetPluginStoreListHandler, GetPluginStoreListHandler,
@@ -68,6 +69,7 @@ router.get('/Config/SSE', PluginConfigSSEHandler);
router.post('/Config/Change', PluginConfigChangeHandler); router.post('/Config/Change', PluginConfigChangeHandler);
router.post('/RegisterManager', RegisterPluginManagerHandler); router.post('/RegisterManager', RegisterPluginManagerHandler);
router.post('/Import', upload.single('plugin'), ImportLocalPluginHandler); router.post('/Import', upload.single('plugin'), ImportLocalPluginHandler);
router.get('/Icon/:pluginId', GetPluginIconHandler);
// 插件商店相关路由 // 插件商店相关路由
router.get('/Store/List', GetPluginStoreListHandler); router.get('/Store/List', GetPluginStoreListHandler);

View File

@@ -11,6 +11,7 @@ import { useState } from 'react';
import key from '@/const/key'; import key from '@/const/key';
import { PluginItem } from '@/controllers/plugin_manager'; import { PluginItem } from '@/controllers/plugin_manager';
import { getPluginIconUrl } from '@/utils/plugin_icon';
/** 提取作者头像 URL */ /** 提取作者头像 URL */
function getAuthorAvatar (homepage?: string, repository?: string): string | undefined { function getAuthorAvatar (homepage?: string, repository?: string): string | undefined {
@@ -66,15 +67,15 @@ const PluginDisplayCard: React.FC<PluginDisplayCardProps> = ({
onConfig, onConfig,
hasConfig = false, 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 isEnabled = status === 'active';
const [processing, setProcessing] = useState(false); const [processing, setProcessing] = useState(false);
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, ''); const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage; const hasBackground = !!backgroundImage;
// 综合尝试提取头像,最后兜底使用 Vercel 风格头像 // 优先使用后端返回的 icon URL需要携带 token否则尝试提取作者头像,最后兜底 Vercel 风格头像
const avatarUrl = getAuthorAvatar(homepage, repository) || `https://avatar.vercel.sh/${encodeURIComponent(name)}`; const avatarUrl = getPluginIconUrl(icon) || getAuthorAvatar(homepage, repository) || `https://avatar.vercel.sh/${encodeURIComponent(name)}`;
const handleToggle = () => { const handleToggle = () => {
setProcessing(true); setProcessing(true);

View File

@@ -26,6 +26,8 @@ export interface PluginItem {
homepage?: string; homepage?: string;
/** 仓库链接 */ /** 仓库链接 */
repository?: string; repository?: string;
/** 插件图标 URL由后端返回 */
icon?: string;
} }
/** 扩展页面信息 */ /** 扩展页面信息 */

View File

@@ -3,7 +3,8 @@ import { Input } from '@heroui/input';
import { Tab, Tabs } from '@heroui/tabs'; import { Tab, Tabs } from '@heroui/tabs';
import { Tooltip } from '@heroui/tooltip'; import { Tooltip } from '@heroui/tooltip';
import { Spinner } from '@heroui/spinner'; 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 toast from 'react-hot-toast';
import { IoMdRefresh, IoMdSearch, IoMdSettings } from 'react-icons/io'; import { IoMdRefresh, IoMdSearch, IoMdSettings } from 'react-icons/io';
import clsx from 'clsx'; import clsx from 'clsx';
@@ -19,6 +20,16 @@ import { PluginStoreItem } from '@/types/plugin-store';
import useDialog from '@/hooks/use-dialog'; import useDialog from '@/hooks/use-dialog';
import key from '@/const/key'; import key from '@/const/key';
/** Fisher-Yates 洗牌算法,返回新数组 */
function shuffleArray<T> (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 { interface EmptySectionProps {
isEmpty: boolean; isEmpty: boolean;
} }
@@ -86,6 +97,10 @@ export default function PluginStorePage () {
const [detailModalOpen, setDetailModalOpen] = useState(false); const [detailModalOpen, setDetailModalOpen] = useState(false);
const [selectedPlugin, setSelectedPlugin] = useState<PluginStoreItem | null>(null); const [selectedPlugin, setSelectedPlugin] = useState<PluginStoreItem | null>(null);
// 分页状态
const ITEMS_PER_PAGE = 20;
const [currentPage, setCurrentPage] = useState(1);
const loadPlugins = async (forceRefresh: boolean = false) => { const loadPlugins = async (forceRefresh: boolean = false) => {
setLoading(true); setLoading(true);
try { try {
@@ -145,6 +160,7 @@ export default function PluginStorePage () {
tools: filtered.filter(p => p.tags?.includes('工具')), tools: filtered.filter(p => p.tags?.includes('工具')),
entertainment: filtered.filter(p => p.tags?.includes('娱乐')), entertainment: filtered.filter(p => p.tags?.includes('娱乐')),
other: filtered.filter(p => !p.tags?.some(t => ['官方', '工具', '娱乐'].includes(t))), other: filtered.filter(p => !p.tags?.some(t => ['官方', '工具', '娱乐'].includes(t))),
random: shuffleArray(filtered),
}; };
return categories; return categories;
@@ -175,9 +191,30 @@ export default function PluginStorePage () {
{ key: 'tools', title: '工具', count: categorizedPlugins.tools?.length || 0 }, { key: 'tools', title: '工具', count: categorizedPlugins.tools?.length || 0 },
{ key: 'entertainment', title: '娱乐', count: categorizedPlugins.entertainment?.length || 0 }, { key: 'entertainment', title: '娱乐', count: categorizedPlugins.entertainment?.length || 0 },
{ key: 'other', title: '其它', count: categorizedPlugins.other?.length || 0 }, { key: 'other', title: '其它', count: categorizedPlugins.other?.length || 0 },
{ key: 'random', title: '随机', count: categorizedPlugins.random?.length || 0 },
]; ];
}, [categorizedPlugins]); }, [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) => { const handleInstall = async (plugin: PluginStoreItem) => {
// 弹窗选择下载镜像 // 弹窗选择下载镜像
setPendingInstallPlugin(plugin); setPendingInstallPlugin(plugin);
@@ -338,7 +375,7 @@ export default function PluginStorePage () {
placeholder='搜索(Ctrl+F)...' placeholder='搜索(Ctrl+F)...'
startContent={<IoMdSearch className='text-default-400' />} startContent={<IoMdSearch className='text-default-400' />}
value={searchQuery} value={searchQuery}
onValueChange={setSearchQuery} onValueChange={handleSearchChange}
className='max-w-xs w-full' className='max-w-xs w-full'
size='sm' size='sm'
isClearable isClearable
@@ -370,7 +407,7 @@ export default function PluginStorePage () {
aria-label='Plugin Store Categories' aria-label='Plugin Store Categories'
className='max-w-full' className='max-w-full'
selectedKey={activeTab} selectedKey={activeTab}
onSelectionChange={(key) => setActiveTab(String(key))} onSelectionChange={(key) => handleTabChange(String(key))}
classNames={{ classNames={{
tabList: 'bg-white/40 dark:bg-black/20 backdrop-blur-md', tabList: 'bg-white/40 dark:bg-black/20 backdrop-blur-md',
cursor: 'bg-white/80 dark:bg-white/10 backdrop-blur-md shadow-sm', cursor: 'bg-white/80 dark:bg-white/10 backdrop-blur-md shadow-sm',
@@ -395,9 +432,9 @@ export default function PluginStorePage () {
</div> </div>
)} )}
<EmptySection isEmpty={!categorizedPlugins[activeTab]?.length} /> <EmptySection isEmpty={!currentCategoryPlugins.length} />
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 justify-start items-stretch gap-4'> <div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 justify-start items-stretch gap-4'>
{categorizedPlugins[activeTab]?.map((plugin) => { {paginatedPlugins.map((plugin) => {
const installInfo = getPluginInstallInfo(plugin); const installInfo = getPluginInstallInfo(plugin);
return ( return (
<PluginStoreCard <PluginStoreCard
@@ -414,6 +451,24 @@ export default function PluginStorePage () {
); );
})} })}
</div> </div>
{/* 分页控件 */}
{totalPages > 1 && (
<div className='flex justify-center mt-6 mb-2'>
<Pagination
total={totalPages}
page={currentPage}
onChange={setCurrentPage}
showControls
showShadow
color='primary'
size='lg'
classNames={{
wrapper: 'backdrop-blur-md bg-white/40 dark:bg-black/20',
}}
/>
</div>
)}
</div> </div>
</div> </div>

View File

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