mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-03-01 08:10: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:
@@ -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