mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-03-01 16:20:25 +00:00
style(webui): 优化插件商店与插件管理界面 UI/UX
- 重构插件卡片样式,采用毛玻璃效果与主题色交互 - 优化插件商店搜索栏布局,增加对顶部搜索及 Ctrl+F 快捷键的支持 - 实现智能头像提取逻辑,支持从 GitHub、自定义域名(Favicon)及 Vercel 自动生成 - 增加插件描述溢出预览(悬停提示及点击展开功能) - 修复标签溢出处理,支持 Tooltip 完整显示 - 增强后端插件列表 API,支持返回主页及仓库信息 - 修复部分类型错误与代码规范问题
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user