mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-03-01 16:20:25 +00:00
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:
@@ -15,6 +15,7 @@ export interface PluginPackageJson {
|
||||
author?: string;
|
||||
homepage?: string;
|
||||
repository?: string | { type: string; url: string; };
|
||||
icon?: string; // 插件图标文件路径(相对于插件目录),如 "icon.png"
|
||||
}
|
||||
|
||||
// ==================== 插件配置 Schema ====================
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<PluginDisplayCardProps> = ({
|
||||
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<string>(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);
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface PluginItem {
|
||||
homepage?: string;
|
||||
/** 仓库链接 */
|
||||
repository?: string;
|
||||
/** 插件图标 URL(由后端返回) */
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
/** 扩展页面信息 */
|
||||
|
||||
@@ -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<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 {
|
||||
isEmpty: boolean;
|
||||
}
|
||||
@@ -86,6 +97,10 @@ export default function PluginStorePage () {
|
||||
const [detailModalOpen, setDetailModalOpen] = useState(false);
|
||||
const [selectedPlugin, setSelectedPlugin] = useState<PluginStoreItem | null>(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={<IoMdSearch className='text-default-400' />}
|
||||
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 () {
|
||||
</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'>
|
||||
{categorizedPlugins[activeTab]?.map((plugin) => {
|
||||
{paginatedPlugins.map((plugin) => {
|
||||
const installInfo = getPluginInstallInfo(plugin);
|
||||
return (
|
||||
<PluginStoreCard
|
||||
@@ -414,6 +451,24 @@ export default function PluginStorePage () {
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
|
||||
|
||||
20
packages/napcat-webui-frontend/src/utils/plugin_icon.ts
Normal file
20
packages/napcat-webui-frontend/src/utils/plugin_icon.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user