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

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

View File

@@ -287,6 +287,95 @@ async function extractPlugin (zipPath: string, pluginId: string): Promise<void>
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, {
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',

View File

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