mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-02-11 23:40:24 +00:00
style(webui): 优化插件商店与插件管理界面 UI/UX
- 重构插件卡片样式,采用毛玻璃效果与主题色交互 - 优化插件商店搜索栏布局,增加对顶部搜索及 Ctrl+F 快捷键的支持 - 实现智能头像提取逻辑,支持从 GitHub、自定义域名(Favicon)及 Vercel 自动生成 - 增加插件描述溢出预览(悬停提示及点击展开功能) - 修复标签溢出处理,支持 Tooltip 完整显示 - 增强后端插件列表 API,支持返回主页及仓库信息 - 修复部分类型错误与代码规范问题
This commit is contained in:
@@ -13,6 +13,8 @@ export interface PluginPackageJson {
|
||||
main?: string;
|
||||
description?: string;
|
||||
author?: string;
|
||||
homepage?: string;
|
||||
repository?: string | { type: string; url: string; };
|
||||
}
|
||||
|
||||
// ==================== 插件配置 Schema ====================
|
||||
|
||||
@@ -390,7 +390,7 @@ export async function NCoreInitShell () {
|
||||
// 初始化 FFmpeg 服务
|
||||
await FFmpegService.init(pathWrapper.binaryPath, logger);
|
||||
|
||||
if (!(process.env['NAPCAT_DISABLE_PIPE'] == '1' || process.env['NAPCAT_WORKER_PROCESS'] == '1')) {
|
||||
if (!(process.env['NAPCAT_DISABLE_PIPE'] === '1' || process.env['NAPCAT_WORKER_PROCESS'] === '1')) {
|
||||
await connectToNamedPipe(logger).catch(e => logger.logError('命名管道连接失败', e));
|
||||
}
|
||||
const basicInfoWrapper = new QQBasicInfoWrapper({ logger });
|
||||
|
||||
@@ -111,7 +111,11 @@ export const GetPluginListHandler: RequestHandler = async (_req, res) => {
|
||||
author: p.packageJson?.author || '',
|
||||
status,
|
||||
hasConfig: !!(p.runtime.module?.plugin_config_schema || p.runtime.module?.plugin_config_ui),
|
||||
hasPages
|
||||
hasPages,
|
||||
homepage: p.packageJson?.homepage,
|
||||
repository: typeof p.packageJson?.repository === 'string'
|
||||
? p.packageJson.repository
|
||||
: p.packageJson?.repository?.url
|
||||
});
|
||||
|
||||
// 收集插件的扩展页面
|
||||
|
||||
@@ -40,7 +40,7 @@ async function fetchPluginList (forceRefresh: boolean = false): Promise<PluginSt
|
||||
// 检查缓存(如果不是强制刷新)
|
||||
const now = Date.now();
|
||||
if (!forceRefresh && pluginListCache && (now - cacheTimestamp) < CACHE_TTL) {
|
||||
//console.log('Using cached plugin list');
|
||||
// console.log('Using cached plugin list');
|
||||
return pluginListCache;
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ async function fetchPluginList (forceRefresh: boolean = false): Promise<PluginSt
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
//console.log(`Successfully fetched plugin list from: ${url}`);
|
||||
// console.log(`Successfully fetched plugin list from: ${url}`);
|
||||
|
||||
// 更新缓存
|
||||
pluginListCache = data as PluginStoreList;
|
||||
@@ -86,7 +86,13 @@ async function fetchPluginList (forceRefresh: boolean = false): Promise<PluginSt
|
||||
* 下载文件,使用镜像系统
|
||||
* 自动识别 GitHub Release URL 并使用镜像加速
|
||||
*/
|
||||
async function downloadFile (url: string, destPath: string, customMirror?: string): Promise<void> {
|
||||
async function downloadFile (
|
||||
url: string,
|
||||
destPath: string,
|
||||
customMirror?: string,
|
||||
onProgress?: (percent: number, downloaded: number, total: number, speed: number) => void,
|
||||
timeout: number = 120000 // 默认120秒超时
|
||||
): Promise<void> {
|
||||
try {
|
||||
let downloadUrl: string;
|
||||
|
||||
@@ -126,7 +132,7 @@ async function downloadFile (url: string, destPath: string, customMirror?: strin
|
||||
headers: {
|
||||
'User-Agent': 'NapCat-WebUI',
|
||||
},
|
||||
signal: AbortSignal.timeout(120000), // 实际下载120秒超时
|
||||
signal: AbortSignal.timeout(timeout), // 使用传入的超时时间
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -137,9 +143,45 @@ async function downloadFile (url: string, destPath: string, customMirror?: strin
|
||||
throw new Error('Response body is null');
|
||||
}
|
||||
|
||||
const totalLength = Number(response.headers.get('content-length')) || 0;
|
||||
|
||||
// 初始进度通知
|
||||
if (onProgress) {
|
||||
onProgress(0, 0, totalLength, 0);
|
||||
}
|
||||
|
||||
let downloaded = 0;
|
||||
let lastTime = Date.now();
|
||||
let lastDownloaded = 0;
|
||||
|
||||
// 进度监控流
|
||||
// eslint-disable-next-line @stylistic/generator-star-spacing
|
||||
const progressMonitor = async function* (source: any) {
|
||||
for await (const chunk of source) {
|
||||
downloaded += chunk.length;
|
||||
const now = Date.now();
|
||||
const elapsedSinceLast = now - lastTime;
|
||||
|
||||
// 每隔 500ms 或完成时计算一次速度并更新进度
|
||||
if (elapsedSinceLast >= 500 || (totalLength && downloaded === totalLength)) {
|
||||
const percent = totalLength ? Math.round((downloaded / totalLength) * 100) : 0;
|
||||
const speed = (downloaded - lastDownloaded) / (elapsedSinceLast / 1000); // bytes/s
|
||||
|
||||
if (onProgress) {
|
||||
onProgress(percent, downloaded, totalLength, speed);
|
||||
}
|
||||
|
||||
lastTime = now;
|
||||
lastDownloaded = downloaded;
|
||||
}
|
||||
|
||||
yield chunk;
|
||||
}
|
||||
};
|
||||
|
||||
// 写入文件
|
||||
const fileStream = createWriteStream(destPath);
|
||||
await pipeline(response.body as any, fileStream);
|
||||
await pipeline(progressMonitor(response.body), fileStream);
|
||||
|
||||
console.log(`Successfully downloaded to: ${destPath}`);
|
||||
} catch (e: any) {
|
||||
@@ -210,7 +252,7 @@ async function extractPlugin (zipPath: string, pluginId: string): Promise<void>
|
||||
} catch (e) {
|
||||
// 解压失败时,尝试恢复 data 文件夹
|
||||
if (hasDataBackup && fs.existsSync(tempDataDir)) {
|
||||
console.log(`[extractPlugin] Extract failed, restoring data directory`);
|
||||
console.log('[extractPlugin] Extract failed, restoring data directory');
|
||||
if (!fs.existsSync(pluginDir)) {
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
}
|
||||
@@ -224,7 +266,7 @@ async function extractPlugin (zipPath: string, pluginId: string): Promise<void>
|
||||
|
||||
// 列出解压后的文件
|
||||
const files = fs.readdirSync(pluginDir);
|
||||
console.log(`[extractPlugin] Extracted files:`, files);
|
||||
console.log('[extractPlugin] Extracted files:', files);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -279,12 +321,21 @@ export const InstallPluginFromStoreHandler: RequestHandler = async (req, res) =>
|
||||
return sendError(res, 'Plugin not found in store');
|
||||
}
|
||||
|
||||
// 检查是否已安装相同版本
|
||||
const pm = getPluginManager();
|
||||
if (pm) {
|
||||
const installedInfo = pm.getPluginInfo(id);
|
||||
if (installedInfo && installedInfo.version === plugin.version) {
|
||||
return sendError(res, '该插件已安装且版本相同,无需重复安装');
|
||||
}
|
||||
}
|
||||
|
||||
// 下载插件
|
||||
const PLUGINS_DIR = getPluginsDir();
|
||||
const tempZipPath = path.join(PLUGINS_DIR, `${id}.temp.zip`);
|
||||
|
||||
try {
|
||||
await downloadFile(plugin.downloadUrl, tempZipPath, mirror);
|
||||
await downloadFile(plugin.downloadUrl, tempZipPath, mirror, undefined, 300000);
|
||||
|
||||
// 解压插件
|
||||
await extractPlugin(tempZipPath, id);
|
||||
@@ -305,7 +356,7 @@ export const InstallPluginFromStoreHandler: RequestHandler = async (req, res) =>
|
||||
|
||||
return sendSuccess(res, {
|
||||
message: 'Plugin installed successfully',
|
||||
plugin: plugin,
|
||||
plugin,
|
||||
installPath: path.join(PLUGINS_DIR, id),
|
||||
});
|
||||
} catch (downloadError: any) {
|
||||
@@ -337,8 +388,8 @@ export const InstallPluginFromStoreSSEHandler: RequestHandler = async (req, res)
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.flushHeaders();
|
||||
|
||||
const sendProgress = (message: string, progress?: number) => {
|
||||
res.write(`data: ${JSON.stringify({ message, progress })}\n\n`);
|
||||
const sendProgress = (message: string, progress?: number, detail?: any) => {
|
||||
res.write(`data: ${JSON.stringify({ message, progress, ...detail })}\n\n`);
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -355,6 +406,18 @@ export const InstallPluginFromStoreSSEHandler: RequestHandler = async (req, res)
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否已安装相同版本
|
||||
const pm = getPluginManager();
|
||||
if (pm) {
|
||||
const installedInfo = pm.getPluginInfo(id);
|
||||
if (installedInfo && installedInfo.version === plugin.version) {
|
||||
sendProgress('错误: 该插件已安装且版本相同', 0);
|
||||
res.write(`data: ${JSON.stringify({ error: '该插件已安装且版本相同,无需重复安装' })}\n\n`);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
sendProgress(`找到插件: ${plugin.name} v${plugin.version}`, 20);
|
||||
sendProgress(`下载地址: ${plugin.downloadUrl}`, 25);
|
||||
|
||||
@@ -368,12 +431,28 @@ export const InstallPluginFromStoreSSEHandler: RequestHandler = async (req, res)
|
||||
|
||||
try {
|
||||
sendProgress('正在下载插件...', 30);
|
||||
await downloadFile(plugin.downloadUrl, tempZipPath, mirror as string | undefined);
|
||||
await downloadFile(plugin.downloadUrl, tempZipPath, mirror as string | undefined, (percent, downloaded, total, speed) => {
|
||||
const overallProgress = 30 + Math.round(percent * 0.5);
|
||||
const downloadedMb = (downloaded / 1024 / 1024).toFixed(1);
|
||||
const totalMb = total ? (total / 1024 / 1024).toFixed(1) : '?';
|
||||
const speedMb = (speed / 1024 / 1024).toFixed(2);
|
||||
const eta = (total > 0 && speed > 0) ? Math.round((total - downloaded) / speed) : -1;
|
||||
|
||||
sendProgress('下载完成,正在解压...', 70);
|
||||
sendProgress(`正在下载插件... ${percent}%`, overallProgress, {
|
||||
downloaded,
|
||||
total,
|
||||
speed,
|
||||
eta,
|
||||
downloadedStr: `${downloadedMb}MB`,
|
||||
totalStr: `${totalMb}MB`,
|
||||
speedStr: `${speedMb}MB/s`,
|
||||
});
|
||||
}, 300000);
|
||||
|
||||
sendProgress('下载完成,正在解压...', 85);
|
||||
await extractPlugin(tempZipPath, id);
|
||||
|
||||
sendProgress('解压完成,正在清理...', 90);
|
||||
sendProgress('解压完成,正在清理...', 95);
|
||||
fs.unlinkSync(tempZipPath);
|
||||
|
||||
// 如果 pluginManager 存在,立即注册或重载插件
|
||||
@@ -393,7 +472,7 @@ export const InstallPluginFromStoreSSEHandler: RequestHandler = async (req, res)
|
||||
res.write(`data: ${JSON.stringify({
|
||||
success: true,
|
||||
message: 'Plugin installed successfully',
|
||||
plugin: plugin,
|
||||
plugin,
|
||||
installPath: path.join(PLUGINS_DIR, id),
|
||||
})}\n\n`);
|
||||
res.end();
|
||||
|
||||
@@ -1,13 +1,56 @@
|
||||
import { Avatar } from '@heroui/avatar';
|
||||
import { Button } from '@heroui/button';
|
||||
import { Switch } from '@heroui/switch';
|
||||
import { Card, CardBody, CardFooter } from '@heroui/card';
|
||||
import { Chip } from '@heroui/chip';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Switch } from '@heroui/switch';
|
||||
import { Tooltip } from '@heroui/tooltip';
|
||||
import { MdDeleteForever, MdSettings } from 'react-icons/md';
|
||||
import clsx from 'clsx';
|
||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||
import { useState } from 'react';
|
||||
|
||||
import DisplayCardContainer from './container';
|
||||
import key from '@/const/key';
|
||||
import { PluginItem } from '@/controllers/plugin_manager';
|
||||
|
||||
/** 提取作者头像 URL */
|
||||
function getAuthorAvatar (homepage?: string, repository?: string): string | undefined {
|
||||
// 1. 尝试从 repository 提取 GitHub 用户名
|
||||
if (repository) {
|
||||
try {
|
||||
// 处理 git+https://github.com/... 或 https://github.com/...
|
||||
const repoUrl = repository.replace(/^git\+/, '').replace(/\.git$/, '');
|
||||
const url = new URL(repoUrl);
|
||||
if (url.hostname === 'github.com' || url.hostname === 'www.github.com') {
|
||||
const parts = url.pathname.split('/').filter(Boolean);
|
||||
if (parts.length >= 1) {
|
||||
return `https://github.com/${parts[0]}.png`;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 忽略解析错误
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 尝试从 homepage 提取
|
||||
if (homepage) {
|
||||
try {
|
||||
const url = new URL(homepage);
|
||||
if (url.hostname === 'github.com' || url.hostname === 'www.github.com') {
|
||||
const parts = url.pathname.split('/').filter(Boolean);
|
||||
if (parts.length >= 1) {
|
||||
return `https://github.com/${parts[0]}.png`;
|
||||
}
|
||||
} else {
|
||||
// 如果是自定义域名,尝试获取 favicon
|
||||
return `https://api.iowen.cn/favicon/${url.hostname}.png`;
|
||||
}
|
||||
} catch {
|
||||
// 忽略解析错误
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export interface PluginDisplayCardProps {
|
||||
data: PluginItem;
|
||||
onToggleStatus: () => Promise<void>;
|
||||
@@ -23,9 +66,15 @@ const PluginDisplayCard: React.FC<PluginDisplayCardProps> = ({
|
||||
onConfig,
|
||||
hasConfig = false,
|
||||
}) => {
|
||||
const { name, version, author, description, status } = data;
|
||||
const { name, version, author, description, status, homepage, repository } = 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)}`;
|
||||
|
||||
const handleToggle = () => {
|
||||
setProcessing(true);
|
||||
@@ -38,88 +87,132 @@ const PluginDisplayCard: React.FC<PluginDisplayCardProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<DisplayCardContainer
|
||||
className='w-full max-w-[420px]'
|
||||
action={
|
||||
<div className='flex flex-col gap-2 w-full'>
|
||||
<div className='flex gap-2 w-full'>
|
||||
<Button
|
||||
fullWidth
|
||||
<Card
|
||||
className={clsx(
|
||||
'group w-full backdrop-blur-md rounded-2xl overflow-hidden transition-all duration-300',
|
||||
'hover:shadow-xl hover:-translate-y-1',
|
||||
'border border-white/50 dark:border-white/10 hover:border-primary/50 dark:hover:border-primary/50',
|
||||
hasBackground ? 'bg-white/20 dark:bg-black/10' : 'bg-white/60 dark:bg-black/30'
|
||||
)}
|
||||
shadow='sm'
|
||||
>
|
||||
<CardBody className='p-4 flex flex-col gap-3'>
|
||||
{/* Header */}
|
||||
<div className='flex items-start justify-between gap-3'>
|
||||
<div className='flex items-center gap-3 min-w-0'>
|
||||
<Avatar
|
||||
src={avatarUrl}
|
||||
name={author || '?'}
|
||||
className='flex-shrink-0'
|
||||
size='md'
|
||||
isBordered
|
||||
radius='full'
|
||||
size='sm'
|
||||
variant='flat'
|
||||
className='flex-1 bg-default-100 dark:bg-default-50 text-default-600 font-medium hover:bg-danger/20 hover:text-danger transition-colors'
|
||||
startContent={<MdDeleteForever size={16} />}
|
||||
onPress={handleUninstall}
|
||||
isDisabled={processing}
|
||||
>
|
||||
卸载
|
||||
</Button>
|
||||
color='default'
|
||||
/>
|
||||
<div className='min-w-0'>
|
||||
<h3 className='text-base font-bold text-default-900 truncate' title={name}>
|
||||
{name}
|
||||
</h3>
|
||||
<p className='text-xs text-default-500 mt-0.5 truncate'>
|
||||
by <span className='font-medium'>{author || '未知'}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{hasConfig && (
|
||||
<Button
|
||||
fullWidth
|
||||
radius='full'
|
||||
size='sm'
|
||||
variant='flat'
|
||||
className='bg-default-100 dark:bg-default-50 text-default-600 font-medium hover:bg-secondary/20 hover:text-secondary transition-colors'
|
||||
startContent={<MdSettings size={16} />}
|
||||
onPress={onConfig}
|
||||
>
|
||||
配置
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='flat'
|
||||
color={status === 'active' ? 'success' : status === 'stopped' ? 'warning' : 'default'}
|
||||
className='flex-shrink-0 font-medium h-6 px-1'
|
||||
>
|
||||
{status === 'active' ? '运行中' : status === 'stopped' ? '已停止' : '已禁用'}
|
||||
</Chip>
|
||||
</div>
|
||||
}
|
||||
enableSwitch={
|
||||
|
||||
{/* Description */}
|
||||
<div
|
||||
className='relative min-h-[2.5rem] cursor-pointer group/desc'
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<Tooltip
|
||||
content={description}
|
||||
isDisabled={!description || description.length < 50 || isExpanded}
|
||||
placement='bottom'
|
||||
className='max-w-[280px]'
|
||||
delay={500}
|
||||
>
|
||||
<p className={clsx(
|
||||
'text-sm text-default-600 dark:text-default-400 leading-relaxed transition-all duration-300',
|
||||
isExpanded ? 'line-clamp-none' : 'line-clamp-2'
|
||||
)}
|
||||
>
|
||||
{description || '暂无描述'}
|
||||
</p>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Version Badge */}
|
||||
<div>
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='flat'
|
||||
color='primary'
|
||||
className='h-5 text-xs font-semibold px-0.5'
|
||||
classNames={{ content: 'px-1' }}
|
||||
>
|
||||
v{version}
|
||||
</Chip>
|
||||
</div>
|
||||
</CardBody>
|
||||
|
||||
<CardFooter className='px-4 pb-4 pt-0 gap-3'>
|
||||
<Switch
|
||||
isDisabled={processing}
|
||||
isSelected={isEnabled}
|
||||
onChange={handleToggle}
|
||||
onValueChange={handleToggle}
|
||||
size='sm'
|
||||
color='success'
|
||||
classNames={{
|
||||
wrapper: 'group-data-[selected=true]:bg-primary-400',
|
||||
wrapper: 'group-data-[selected=true]:bg-success',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
title={name}
|
||||
tag={
|
||||
<Chip
|
||||
className="ml-auto"
|
||||
color={status === 'active' ? 'success' : status === 'stopped' ? 'warning' : 'default'}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
{status === 'active' ? '运行中' : status === 'stopped' ? '已停止' : '已禁用'}
|
||||
</Chip>
|
||||
}
|
||||
>
|
||||
<div className='grid grid-cols-2 gap-3'>
|
||||
<div className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors'>
|
||||
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>
|
||||
版本
|
||||
<span className='text-xs font-medium text-default-600'>
|
||||
{isEnabled ? '已启用' : '已禁用'}
|
||||
</span>
|
||||
<div className='text-sm font-medium text-default-700 dark:text-white/90 truncate'>
|
||||
{version}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors'>
|
||||
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>
|
||||
作者
|
||||
</span>
|
||||
<div className='text-sm font-medium text-default-700 dark:text-white/90 truncate'>
|
||||
{author || '未知'}
|
||||
</div>
|
||||
</div>
|
||||
<div className='col-span-2 flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors'>
|
||||
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>
|
||||
描述
|
||||
</span>
|
||||
<div className='text-sm font-medium text-default-700 dark:text-white/90 break-words line-clamp-2'>
|
||||
{description || '暂无描述'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DisplayCardContainer>
|
||||
</Switch>
|
||||
|
||||
<div className='flex-1' />
|
||||
|
||||
{hasConfig && (
|
||||
<Tooltip content='插件配置'>
|
||||
<Button
|
||||
isIconOnly
|
||||
radius='full'
|
||||
size='sm'
|
||||
variant='light'
|
||||
color='primary'
|
||||
onPress={onConfig}
|
||||
>
|
||||
<MdSettings size={20} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip content='卸载插件' color='danger'>
|
||||
<Button
|
||||
isIconOnly
|
||||
radius='full'
|
||||
size='sm'
|
||||
variant='light'
|
||||
color='danger'
|
||||
onPress={handleUninstall}
|
||||
isDisabled={processing}
|
||||
>
|
||||
<MdDeleteForever size={20} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,17 +1,60 @@
|
||||
/* eslint-disable @stylistic/indent */
|
||||
import { Avatar } from '@heroui/avatar';
|
||||
import { Button } from '@heroui/button';
|
||||
import { Card, CardBody, CardFooter } from '@heroui/card';
|
||||
import { Chip } from '@heroui/chip';
|
||||
import { Tooltip } from '@heroui/tooltip';
|
||||
import { IoMdCheckmarkCircle } from 'react-icons/io';
|
||||
import { MdUpdate, MdOutlineGetApp } from 'react-icons/md';
|
||||
import clsx from 'clsx';
|
||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||
import { useState } from 'react';
|
||||
import { IoMdDownload, IoMdRefresh, IoMdCheckmarkCircle } from 'react-icons/io';
|
||||
|
||||
import DisplayCardContainer from './container';
|
||||
import key from '@/const/key';
|
||||
import { PluginStoreItem } from '@/types/plugin-store';
|
||||
|
||||
export type InstallStatus = 'not-installed' | 'installed' | 'update-available';
|
||||
|
||||
/** 提取作者头像 URL */
|
||||
function getAuthorAvatar (homepage?: string, downloadUrl?: string): string | undefined {
|
||||
// 1. 尝试从 downloadUrl 提取 GitHub 用户名 (通常是最准确的源码仓库所有者)
|
||||
if (downloadUrl) {
|
||||
try {
|
||||
const url = new URL(downloadUrl);
|
||||
if (url.hostname === 'github.com' || url.hostname === 'www.github.com') {
|
||||
const parts = url.pathname.split('/').filter(Boolean);
|
||||
if (parts.length >= 1) {
|
||||
return `https://github.com/${parts[0]}.png`;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 忽略解析错误
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 尝试从 homepage 提取
|
||||
if (homepage) {
|
||||
try {
|
||||
const url = new URL(homepage);
|
||||
if (url.hostname === 'github.com' || url.hostname === 'www.github.com') {
|
||||
const parts = url.pathname.split('/').filter(Boolean);
|
||||
if (parts.length >= 1) {
|
||||
return `https://github.com/${parts[0]}.png`;
|
||||
}
|
||||
} else {
|
||||
// 如果是自定义域名,尝试获取 favicon。使用主流的镜像服务以保证国内访问速度
|
||||
return `https://api.iowen.cn/favicon/${url.hostname}.png`;
|
||||
}
|
||||
} catch {
|
||||
// 忽略解析错误
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export interface PluginStoreCardProps {
|
||||
data: PluginStoreItem;
|
||||
onInstall: () => Promise<void>;
|
||||
onInstall: () => void;
|
||||
installStatus?: InstallStatus;
|
||||
installedVersion?: string;
|
||||
}
|
||||
@@ -20,158 +63,222 @@ const PluginStoreCard: React.FC<PluginStoreCardProps> = ({
|
||||
data,
|
||||
onInstall,
|
||||
installStatus = 'not-installed',
|
||||
installedVersion,
|
||||
}) => {
|
||||
const { name, version, author, description, tags, id, homepage } = data;
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const { name, version, author, description, tags, homepage, downloadUrl } = data;
|
||||
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const hasBackground = !!backgroundImage;
|
||||
|
||||
const handleInstall = () => {
|
||||
setProcessing(true);
|
||||
onInstall().finally(() => setProcessing(false));
|
||||
};
|
||||
// 综合尝试提取头像,最后兜底使用 Vercel 风格头像
|
||||
const avatarUrl = getAuthorAvatar(homepage, downloadUrl) || `https://avatar.vercel.sh/${encodeURIComponent(name)}`;
|
||||
|
||||
// 根据安装状态返回按钮配置
|
||||
const getButtonConfig = () => {
|
||||
switch (installStatus) {
|
||||
case 'installed':
|
||||
return {
|
||||
text: '重新安装',
|
||||
icon: <IoMdRefresh size={16} />,
|
||||
color: 'default' as const,
|
||||
};
|
||||
case 'update-available':
|
||||
return {
|
||||
text: '更新',
|
||||
icon: <IoMdDownload size={16} />,
|
||||
color: 'default' as const,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
text: '安装',
|
||||
icon: <IoMdDownload size={16} />,
|
||||
color: 'primary' as const,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const buttonConfig = getButtonConfig();
|
||||
const titleContent = homepage ? (
|
||||
<Tooltip
|
||||
content="跳转到插件主页"
|
||||
placement="top"
|
||||
showArrow
|
||||
offset={8}
|
||||
delay={200}
|
||||
>
|
||||
<a
|
||||
href={homepage}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-inherit inline-block bg-no-repeat bg-left-bottom [background-image:repeating-linear-gradient(90deg,currentColor_0_2px,transparent_2px_5px)] [background-size:0%_2px] hover:[background-size:100%_2px] transition-[background-size] duration-200 ease-out"
|
||||
>
|
||||
{name}
|
||||
</a>
|
||||
</Tooltip>
|
||||
) : (
|
||||
name
|
||||
// 作者链接组件
|
||||
const AuthorComponent = (
|
||||
<span className={clsx('font-medium transition-colors', homepage ? 'hover:text-primary hover:underline cursor-pointer' : '')}>
|
||||
{author || '未知作者'}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<DisplayCardContainer
|
||||
className='w-full max-w-[420px]'
|
||||
title={titleContent}
|
||||
tag={
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
{installStatus === 'installed' && (
|
||||
<Chip
|
||||
color="success"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
startContent={<IoMdCheckmarkCircle size={14} />}
|
||||
<Card
|
||||
className={clsx(
|
||||
'group w-full backdrop-blur-md rounded-2xl overflow-hidden transition-all duration-300',
|
||||
'hover:shadow-xl hover:-translate-y-1',
|
||||
// 降低边框粗细
|
||||
'border border-white/50 dark:border-white/10 hover:border-primary/50 dark:hover:border-primary/50',
|
||||
hasBackground ? 'bg-white/20 dark:bg-black/10' : 'bg-white/60 dark:bg-black/30'
|
||||
)}
|
||||
shadow='sm'
|
||||
>
|
||||
<CardBody className='p-4 flex flex-col gap-3'>
|
||||
{/* Header: Avatar + Name + Author */}
|
||||
<div className='flex items-start gap-3'>
|
||||
<Avatar
|
||||
src={avatarUrl}
|
||||
name={author || '?'}
|
||||
size='md'
|
||||
isBordered
|
||||
color='default'
|
||||
radius='full' // 圆形头像
|
||||
className='flex-shrink-0 transition-transform group-hover:scale-105'
|
||||
/>
|
||||
<div className='flex-1 min-w-0'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-1.5 min-w-0'>
|
||||
{homepage
|
||||
? (
|
||||
<Tooltip content='访问项目主页' placement='top' delay={500}>
|
||||
<a
|
||||
href={homepage}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
className='text-base font-bold text-default-900 hover:text-primary transition-colors truncate'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{name}
|
||||
</a>
|
||||
</Tooltip>
|
||||
)
|
||||
: (
|
||||
<h3 className='text-base font-bold text-default-900 truncate' title={name}>
|
||||
{name}
|
||||
</h3>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 可点击的作者名称 */}
|
||||
<div className='text-xs text-default-500 mt-0.5 truncate'>
|
||||
by {homepage
|
||||
? (
|
||||
<a
|
||||
href={homepage}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{AuthorComponent}
|
||||
</a>
|
||||
)
|
||||
: AuthorComponent}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div
|
||||
className='relative min-h-[2.5rem] cursor-pointer group/desc'
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<Tooltip
|
||||
content={description}
|
||||
isDisabled={!description || description.length < 50 || isExpanded}
|
||||
placement='bottom'
|
||||
className='max-w-[280px]'
|
||||
delay={500}
|
||||
>
|
||||
<p className={clsx(
|
||||
'text-sm text-default-600 dark:text-default-400 leading-relaxed transition-all duration-300',
|
||||
isExpanded ? 'line-clamp-none' : 'line-clamp-2'
|
||||
)}
|
||||
>
|
||||
已安装
|
||||
</Chip>
|
||||
)}
|
||||
{installStatus === 'update-available' && (
|
||||
<Chip
|
||||
color="warning"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
可更新
|
||||
</Chip>
|
||||
)}
|
||||
{description || '暂无描述'}
|
||||
</p>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Tags & Version */}
|
||||
<div className='flex items-center gap-1.5 flex-wrap'>
|
||||
<Chip
|
||||
color="primary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
size='sm'
|
||||
variant='flat'
|
||||
color='primary'
|
||||
className='h-5 text-xs font-semibold px-0.5'
|
||||
classNames={{ content: 'px-1' }}
|
||||
>
|
||||
v{version}
|
||||
</Chip>
|
||||
|
||||
{/* Tags with proper truncation and hover */}
|
||||
{tags?.slice(0, 2).map((tag) => (
|
||||
<Chip
|
||||
key={tag}
|
||||
size='sm'
|
||||
variant='flat'
|
||||
className='h-5 text-xs px-0.5 bg-default-100 dark:bg-default-50/50 text-default-600'
|
||||
classNames={{ content: 'px-1' }}
|
||||
>
|
||||
{tag}
|
||||
</Chip>
|
||||
))}
|
||||
|
||||
{tags && tags.length > 2 && (
|
||||
<Tooltip
|
||||
content={
|
||||
<div className='flex flex-wrap gap-1 max-w-[200px] p-1'>
|
||||
{tags.map(t => (
|
||||
<span key={t} className='text-xs bg-white/10 px-1.5 py-0.5 rounded-md border border-white/10'>
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
delay={0}
|
||||
closeDelay={0}
|
||||
>
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='flat'
|
||||
className='h-5 text-xs px-0.5 cursor-pointer hover:bg-default-200 transition-colors'
|
||||
classNames={{ content: 'px-1' }}
|
||||
>
|
||||
+{tags.length - 2}
|
||||
</Chip>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{installStatus === 'update-available' && installedVersion && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='shadow'
|
||||
color='warning'
|
||||
className='h-5 text-xs font-semibold px-0.5 ml-auto animate-pulse'
|
||||
classNames={{ content: 'px-1' }}
|
||||
>
|
||||
新版本
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
enableSwitch={undefined}
|
||||
action={
|
||||
<Button
|
||||
fullWidth
|
||||
radius='full'
|
||||
size='sm'
|
||||
color={buttonConfig.color}
|
||||
startContent={buttonConfig.icon}
|
||||
onPress={handleInstall}
|
||||
isLoading={processing}
|
||||
isDisabled={processing}
|
||||
>
|
||||
{buttonConfig.text}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className='grid grid-cols-2 gap-3'>
|
||||
<div className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors'>
|
||||
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>
|
||||
作者
|
||||
</span>
|
||||
<div className='text-sm font-medium text-default-700 dark:text-white/90 truncate'>
|
||||
{author || '未知'}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors'>
|
||||
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>
|
||||
版本
|
||||
</span>
|
||||
<div className='text-sm font-medium text-default-700 dark:text-white/90 truncate'>
|
||||
v{version}
|
||||
</div>
|
||||
</div>
|
||||
<div className='col-span-2 flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors'>
|
||||
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>
|
||||
描述
|
||||
</span>
|
||||
<div className='text-sm font-medium text-default-700 dark:text-white/90 break-words line-clamp-2 h-10 overflow-hidden'>
|
||||
{description || '暂无描述'}
|
||||
</div>
|
||||
</div>
|
||||
{id && (
|
||||
<div className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors'>
|
||||
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>
|
||||
包名
|
||||
</span>
|
||||
<div className='text-sm font-medium text-default-700 dark:text-white/90 break-words line-clamp-2 h-10 overflow-hidden'>
|
||||
{id || '包名'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{tags && tags.length > 0 && (
|
||||
<div className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors'>
|
||||
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>
|
||||
标签
|
||||
</span>
|
||||
<div className='text-sm font-medium text-default-700 dark:text-white/90 truncate'>
|
||||
{tags.slice(0, 2).join(' · ')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DisplayCardContainer>
|
||||
</CardBody>
|
||||
|
||||
<CardFooter className='px-4 pb-4 pt-0'>
|
||||
{installStatus === 'installed'
|
||||
? (
|
||||
<Button
|
||||
fullWidth
|
||||
radius='full'
|
||||
size='sm'
|
||||
color='success'
|
||||
variant='flat'
|
||||
startContent={<IoMdCheckmarkCircle size={18} />}
|
||||
className='font-medium bg-success/20 text-success dark:bg-success/20 cursor-default'
|
||||
isDisabled
|
||||
>
|
||||
已安装
|
||||
</Button>
|
||||
)
|
||||
: installStatus === 'update-available'
|
||||
? (
|
||||
<Button
|
||||
fullWidth
|
||||
radius='full'
|
||||
size='sm'
|
||||
color='warning'
|
||||
variant='shadow'
|
||||
className='font-medium text-white shadow-warning/30 hover:shadow-warning/50 transition-shadow'
|
||||
startContent={<MdUpdate size={18} />}
|
||||
onPress={onInstall}
|
||||
>
|
||||
更新到 v{version}
|
||||
</Button>
|
||||
)
|
||||
: (
|
||||
<Button
|
||||
fullWidth
|
||||
radius='full'
|
||||
size='sm'
|
||||
color='primary'
|
||||
variant='bordered'
|
||||
className='font-medium bg-white dark:bg-zinc-900 border hover:bg-primary hover:text-white transition-all shadow-sm group/btn'
|
||||
startContent={<MdOutlineGetApp size={20} className='transition-transform group-hover/btn:translate-y-0.5' />}
|
||||
onPress={onInstall}
|
||||
>
|
||||
立即安装
|
||||
</Button>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -22,6 +22,10 @@ export interface PluginItem {
|
||||
hasConfig?: boolean;
|
||||
/** 是否有扩展页面 */
|
||||
hasPages?: boolean;
|
||||
/** 主页链接 */
|
||||
homepage?: string;
|
||||
/** 仓库链接 */
|
||||
repository?: string;
|
||||
}
|
||||
|
||||
/** 扩展页面信息 */
|
||||
|
||||
@@ -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