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