style(webui): 优化插件商店与插件管理界面 UI/UX

- 重构插件卡片样式,采用毛玻璃效果与主题色交互
- 优化插件商店搜索栏布局,增加对顶部搜索及 Ctrl+F 快捷键的支持
- 实现智能头像提取逻辑,支持从 GitHub、自定义域名(Favicon)及 Vercel 自动生成
- 增加插件描述溢出预览(悬停提示及点击展开功能)
- 修复标签溢出处理,支持 Tooltip 完整显示
- 增强后端插件列表 API,支持返回主页及仓库信息
- 修复部分类型错误与代码规范问题
This commit is contained in:
时瑾
2026-02-07 13:30:50 +08:00
parent 54266f97f8
commit 61a16b44a4
9 changed files with 730 additions and 329 deletions

View File

@@ -18,6 +18,7 @@ interface ExtensionPage {
description?: string;
}
// eslint-disable-next-line @typescript-eslint/no-redeclare
export default function ExtensionPage () {
const [loading, setLoading] = useState(true);
const [extensionPages, setExtensionPages] = useState<ExtensionPage[]>([]);
@@ -150,28 +151,30 @@ export default function ExtensionPage () {
)}
</div>
{extensionPages.length === 0 && !loading ? (
<div className='flex-1 flex flex-col items-center justify-center text-default-400'>
<MdExtension size={64} className='mb-4 opacity-50' />
<p className='text-lg'></p>
<p className='text-sm mt-2'> WebUI </p>
</div>
) : (
<div className='flex-1 min-h-0 bg-white/40 dark:bg-black/20 backdrop-blur-md rounded-lg overflow-hidden relative'>
{iframeLoading && (
<div className='absolute inset-0 flex items-center justify-center bg-default-100/50 z-10'>
<Spinner size='lg' />
</div>
)}
<iframe
src={currentPageUrl}
className='w-full h-full border-0'
onLoad={handleIframeLoad}
title='extension-page'
sandbox='allow-scripts allow-same-origin allow-forms allow-popups'
/>
</div>
)}
{extensionPages.length === 0 && !loading
? (
<div className='flex-1 flex flex-col items-center justify-center text-default-400'>
<MdExtension size={64} className='mb-4 opacity-50' />
<p className='text-lg'></p>
<p className='text-sm mt-2'> WebUI </p>
</div>
)
: (
<div className='flex-1 min-h-0 bg-white/40 dark:bg-black/20 backdrop-blur-md rounded-lg overflow-hidden relative'>
{iframeLoading && (
<div className='absolute inset-0 flex items-center justify-center bg-default-100/50 z-10'>
<Spinner size='lg' />
</div>
)}
<iframe
src={currentPageUrl}
className='w-full h-full border-0'
onLoad={handleIframeLoad}
title='extension-page'
sandbox='allow-scripts allow-same-origin allow-forms allow-popups'
/>
</div>
)}
</div>
</>
);

View File

@@ -1,10 +1,9 @@
import { Button } from '@heroui/button';
import { Input } from '@heroui/input';
import { Tab, Tabs } from '@heroui/tabs';
import { Card, CardBody } from '@heroui/card';
import { Tooltip } from '@heroui/tooltip';
import { Spinner } from '@heroui/spinner';
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useState, useRef } from 'react';
import toast from 'react-hot-toast';
import { IoMdRefresh, IoMdSearch, IoMdSettings } from 'react-icons/io';
import clsx from 'clsx';
@@ -42,6 +41,34 @@ export default function PluginStorePage () {
const [activeTab, setActiveTab] = useState<string>('all');
const [pluginManagerNotFound, setPluginManagerNotFound] = useState(false);
const dialog = useDialog();
const searchInputRef = useRef<HTMLInputElement>(null);
// 快捷键支持: Ctrl+F 聚焦搜索框
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
e.preventDefault();
searchInputRef.current?.focus();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
// 进度条相关状态
const [installProgress, setInstallProgress] = useState<{
show: boolean;
message: string;
progress: number;
speedStr?: string;
eta?: number;
downloadedStr?: string;
totalStr?: string;
}>({
show: false,
message: '',
progress: 0,
});
// 商店列表源相关状态
const [storeSourceModalOpen, setStoreSourceModalOpen] = useState(false);
@@ -103,7 +130,8 @@ export default function PluginStorePage () {
// 获取插件的安装状态和已安装版本
const getPluginInstallInfo = (plugin: PluginStoreItem): { status: InstallStatus; installedVersion?: string; } => {
// 通过 id (包名) 或 name 匹配已安装的插件
const installed = installedPlugins.find(p => p.id === plugin.id);
// 优先匹配 ID如果 ID 匹配失败尝试匹配名称(兼容某些 ID 不一致的情况)
const installed = installedPlugins.find(p => p.id === plugin.id || p.name === plugin.name);
if (!installed) {
return { status: 'not-installed' };
@@ -167,9 +195,11 @@ export default function PluginStorePage () {
if (data.error) {
toast.error(`安装失败: ${data.error}`, { id: loadingToast });
setInstallProgress(prev => ({ ...prev, show: false }));
eventSource.close();
} else if (data.success) {
toast.success('插件安装成功!', { id: loadingToast });
setInstallProgress(prev => ({ ...prev, show: false }));
eventSource.close();
// 刷新插件列表
loadPlugins();
@@ -178,30 +208,46 @@ export default function PluginStorePage () {
dialog.confirm({
title: '插件管理器未加载',
content: (
<div className="space-y-2">
<p className="text-sm text-default-600">
<div className='space-y-2'>
<p className='text-sm text-default-600'>
</p>
<p className="text-sm text-default-600">
<p className='text-sm text-default-600'>
</p>
</div>
),
confirmText: '注册插件管理器',
cancelText: '稍后再说',
onConfirm: async () => {
try {
await PluginManager.registerPluginManager();
toast.success('插件管理器注册成功');
setPluginManagerNotFound(false);
} catch (e: any) {
toast.error('注册失败: ' + e.message);
}
onConfirm: () => {
(async () => {
try {
await PluginManager.registerPluginManager();
toast.success('插件管理器注册成功');
setPluginManagerNotFound(false);
} catch (e: any) {
toast.error('注册失败: ' + e.message);
}
})();
},
});
}
} else if (data.message) {
toast.loading(data.message, { id: loadingToast });
if (typeof data.progress === 'number' && data.progress >= 0 && data.progress <= 100) {
setInstallProgress((prev) => ({
...prev,
show: true,
message: data.message,
progress: data.progress,
// 保存下载详情,避免被后续非下载步骤的消息清空
speedStr: data.speedStr || (data.message.includes('下载') ? prev.speedStr : undefined),
eta: data.eta !== undefined ? data.eta : (data.message.includes('下载') ? prev.eta : undefined),
downloadedStr: data.downloadedStr || (data.message.includes('下载') ? prev.downloadedStr : undefined),
totalStr: data.totalStr || (data.message.includes('下载') ? prev.totalStr : undefined),
}));
} else {
toast.loading(data.message, { id: loadingToast });
}
}
} catch (e) {
console.error('Failed to parse SSE message:', e);
@@ -211,6 +257,7 @@ export default function PluginStorePage () {
eventSource.onerror = (error) => {
console.error('SSE连接出错:', error);
toast.error('连接中断,安装失败', { id: loadingToast });
setInstallProgress(prev => ({ ...prev, show: false }));
eventSource.close();
};
} catch (error: any) {
@@ -233,67 +280,72 @@ export default function PluginStorePage () {
return (
<>
<title> - NapCat WebUI</title>
<div className="p-2 md:p-4 relative">
<div className='p-2 md:p-4 relative'>
{/* 固定头部区域 */}
<div className={clsx(
'sticky top-14 z-10 backdrop-blur-sm py-4 px-4 rounded-sm mb-4 -mx-2 md:-mx-4 -mt-2 md:-mt-4 transition-colors',
'sticky top-14 z-10 backdrop-blur-md py-4 px-4 rounded-sm mb-4 -mx-2 md:-mx-4 -mt-2 md:-mt-4 transition-colors',
hasBackground
? 'bg-white/20 dark:bg-black/10'
: 'bg-transparent'
)}>
{/* 头部 */}
<div className="flex mb-4 items-center justify-between flex-wrap gap-4">
<div className="flex items-center gap-4">
<h1 className="text-2xl font-bold"></h1>
<Button
isIconOnly
className="bg-default-100/50 hover:bg-default-200/50 text-default-700 backdrop-blur-md"
radius="full"
onPress={() => loadPlugins(true)}
isLoading={loading}
>
<IoMdRefresh size={24} />
</Button>
)}
>
{/* 头部布局:标题 + 搜索 + 工具栏 */}
<div className='flex flex-col md:flex-row mb-4 items-start md:items-center justify-between gap-4'>
<div className='flex items-center gap-3 flex-shrink-0'>
<h1 className='text-2xl font-bold'></h1>
<Tooltip content='刷新列表'>
<Button
isIconOnly
size='sm'
variant='flat'
className='bg-default-100/50 hover:bg-default-200/50 text-default-700 backdrop-blur-md'
radius='full'
onPress={() => loadPlugins(true)}
isLoading={loading}
>
<IoMdRefresh size={20} />
</Button>
</Tooltip>
</div>
{/* 商店列表源卡片 */}
<Card className="bg-default-100/50 backdrop-blur-md shadow-sm">
<CardBody className="py-2 px-3">
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<span className="text-xs text-default-500">:</span>
<span className="text-sm font-medium">{getStoreSourceDisplayName()}</span>
</div>
<Tooltip content="切换列表源">
<Button
isIconOnly
size="sm"
variant="light"
onPress={() => setStoreSourceModalOpen(true)}
>
<IoMdSettings size={16} />
</Button>
</Tooltip>
</div>
</CardBody>
</Card>
</div>
{/* 顶栏搜索框与列表源 */}
<div className='flex items-center gap-3 w-full md:w-auto flex-1 justify-end'>
<Input
ref={searchInputRef}
placeholder='搜索(Ctrl+F)...'
startContent={<IoMdSearch className='text-default-400' />}
value={searchQuery}
onValueChange={setSearchQuery}
className='max-w-xs w-full'
size='sm'
isClearable
classNames={{
inputWrapper: 'bg-default-100/50 dark:bg-black/20 backdrop-blur-md border-white/20 dark:border-white/10',
}}
/>
{/* 搜索框 */}
<div className="mb-4">
<Input
placeholder="搜索插件名称、描述、作者或标签..."
startContent={<IoMdSearch className="text-default-400" />}
value={searchQuery}
onValueChange={setSearchQuery}
className="max-w-md"
/>
{/* 商店列表源简易卡片 */}
<div className='hidden sm:flex items-center gap-2 bg-default-100/50 backdrop-blur-md px-3 py-1.5 rounded-full border border-white/20 dark:border-white/10'>
<span className='text-xs text-default-500 whitespace-nowrap'>: {getStoreSourceDisplayName()}</span>
<Tooltip content='切换列表源'>
<Button
isIconOnly
size='sm'
variant='light'
className='min-w-unit-6 w-6 h-6'
onPress={() => setStoreSourceModalOpen(true)}
>
<IoMdSettings size={14} />
</Button>
</Tooltip>
</div>
</div>
</div>
{/* 标签页导航 */}
<Tabs
aria-label="Plugin Store Categories"
className="max-w-full"
aria-label='Plugin Store Categories'
className='max-w-full'
selectedKey={activeTab}
onSelectionChange={(key) => setActiveTab(String(key))}
classNames={{
@@ -312,16 +364,16 @@ export default function PluginStorePage () {
</div>
{/* 插件列表区域 */}
<div className="relative">
<div className='relative'>
{/* 加载遮罩 - 只遮住插件列表区域 */}
{loading && (
<div className="absolute inset-0 bg-zinc-500/10 z-30 flex justify-center items-center backdrop-blur-sm rounded-lg">
<div className='absolute inset-0 bg-zinc-500/10 z-30 flex justify-center items-center backdrop-blur-sm rounded-lg'>
<Spinner size='lg' />
</div>
)}
<EmptySection isEmpty={!categorizedPlugins[activeTab]?.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-x-2 gap-y-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) => {
const installInfo = getPluginInstallInfo(plugin);
return (
@@ -330,7 +382,7 @@ export default function PluginStorePage () {
data={plugin}
installStatus={installInfo.status}
installedVersion={installInfo.installedVersion}
onInstall={() => handleInstall(plugin)}
onInstall={() => { handleInstall(plugin); }}
/>
);
})}
@@ -346,7 +398,7 @@ export default function PluginStorePage () {
setCurrentStoreSource(mirror);
}}
currentMirror={currentStoreSource}
type="raw"
type='raw'
/>
{/* 下载镜像选择弹窗 */}
@@ -366,8 +418,65 @@ export default function PluginStorePage () {
}
}}
currentMirror={selectedDownloadMirror}
type="file"
type='file'
/>
{/* 插件下载进度条全局居中样式 */}
{installProgress.show && (
<div className='fixed inset-0 flex items-center justify-center z-[9999] animate-in fade-in duration-300'>
{/* 毛玻璃背景层 */}
<div className='absolute inset-0 bg-black/20 dark:bg-black/40 backdrop-blur-md' />
<div
className={clsx(
'relative w-[90%] max-w-md bg-white/80 dark:bg-black/70 backdrop-blur-2xl shadow-2xl rounded-2xl border border-white/20 dark:border-white/10 p-8',
'ring-1 ring-black/5 dark:ring-white/5 flex flex-col gap-6'
)}
>
<div className='flex flex-col gap-1'>
<h3 className='text-lg font-bold text-default-900'></h3>
<p className='text-sm text-default-500 font-medium'>{installProgress.message}</p>
</div>
<div className='flex flex-col gap-4'>
{/* 速度 & 百分比 */}
<div className='flex items-center justify-between'>
<div className='flex flex-col gap-0.5'>
{installProgress.speedStr && (
<p className='text-xs text-primary font-bold'>
{installProgress.speedStr}
</p>
)}
{installProgress.eta !== undefined && installProgress.eta !== null && (
<p className='text-xs text-default-400'>
: {
installProgress.eta > 0
? (installProgress.eta < 60 ? `${installProgress.eta}s` : `${Math.floor(installProgress.eta / 60)}m ${installProgress.eta % 60}s`)
: '计算中...'
}
</p>
)}
</div>
<span className='text-2xl font-black text-primary font-mono'>{Math.round(installProgress.progress)}%</span>
</div>
{/* 进度条 */}
<div className='w-full bg-default-200/50 dark:bg-default-100/20 rounded-full h-4 overflow-hidden border border-default-300/20 dark:border-white/5'>
<div
className='bg-primary h-full rounded-full transition-all duration-500 ease-out shadow-[0_0_15px_rgba(var(--heroui-primary),0.6)]'
style={{ width: `${installProgress.progress}%` }}
/>
</div>
{/* 详细数据 (大小) - 始终显示 */}
<div className='flex items-center justify-between text-xs text-default-400 font-bold tracking-tight'>
<span> {installProgress.downloadedStr || '0.0MB'}</span>
<span> {installProgress.totalStr || '获取中...'}</span>
</div>
</div>
</div>
</div>
)}
</>
);
}