mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-02-11 23:40:24 +00:00
feat(webui): 插件商店增加插件详情弹窗并支持通过 url 传递 id 直接打开 (#1615)
* feat(webui): 插件商店增加插件详情弹窗并支持通过 url 传递 id 直接打开 * fix(webui):type check
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -10,6 +10,9 @@ devconfig/*
|
||||
!.vscode/extensions.json
|
||||
.idea/*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# Build
|
||||
*.db
|
||||
checkVersion.sh
|
||||
|
||||
@@ -55,6 +55,7 @@ function getAuthorAvatar (homepage?: string, downloadUrl?: string): string | und
|
||||
export interface PluginStoreCardProps {
|
||||
data: PluginStoreItem;
|
||||
onInstall: () => void;
|
||||
onViewDetail?: () => void;
|
||||
installStatus?: InstallStatus;
|
||||
installedVersion?: string;
|
||||
}
|
||||
@@ -62,6 +63,7 @@ export interface PluginStoreCardProps {
|
||||
const PluginStoreCard: React.FC<PluginStoreCardProps> = ({
|
||||
data,
|
||||
onInstall,
|
||||
onViewDetail,
|
||||
installStatus = 'not-installed',
|
||||
installedVersion,
|
||||
}) => {
|
||||
@@ -91,7 +93,10 @@ const PluginStoreCard: React.FC<PluginStoreCardProps> = ({
|
||||
)}
|
||||
shadow='sm'
|
||||
>
|
||||
<CardBody className='p-4 flex flex-col gap-3'>
|
||||
<CardBody
|
||||
className={clsx('p-4 flex flex-col gap-3', onViewDetail && 'cursor-pointer')}
|
||||
onClick={onViewDetail}
|
||||
>
|
||||
{/* Header: Avatar + Name + Author */}
|
||||
<div className='flex items-start gap-3'>
|
||||
<Avatar
|
||||
@@ -232,7 +237,10 @@ const PluginStoreCard: React.FC<PluginStoreCardProps> = ({
|
||||
</div>
|
||||
</CardBody>
|
||||
|
||||
<CardFooter className='px-4 pb-4 pt-0'>
|
||||
<CardFooter
|
||||
className='px-4 pb-4 pt-0'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{installStatus === 'installed'
|
||||
? (
|
||||
<Button
|
||||
|
||||
@@ -1,43 +1,175 @@
|
||||
import Markdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
const TailwindMarkdown: React.FC<{ content: string }> = ({ content }) => {
|
||||
const TailwindMarkdown: React.FC<{ content: string; }> = ({ content }) => {
|
||||
return (
|
||||
<Markdown
|
||||
className='prose prose-sm sm:prose lg:prose-lg xl:prose-xl'
|
||||
className='prose prose-sm sm:prose lg:prose-lg xl:prose-xl max-w-none'
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
h1: ({ node: _node, ...props }) => (
|
||||
<h1 className='text-2xl font-bold' {...props} />
|
||||
<h1
|
||||
className='text-3xl font-bold mt-6 mb-4 pb-2 border-b-2 border-primary/20 text-default-900 first:mt-0'
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
h2: ({ node: _node, ...props }) => (
|
||||
<h2 className='text-xl font-bold' {...props} />
|
||||
<h2
|
||||
className='text-2xl font-bold mt-6 mb-3 pb-2 border-b border-default-200 text-default-800'
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
h3: ({ node: _node, ...props }) => (
|
||||
<h3 className='text-lg font-bold' {...props} />
|
||||
<h3
|
||||
className='text-xl font-semibold mt-5 mb-2 text-default-800'
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
h4: ({ node: _node, ...props }) => (
|
||||
<h4
|
||||
className='text-lg font-semibold mt-4 mb-2 text-default-700'
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
h5: ({ node: _node, ...props }) => (
|
||||
<h5
|
||||
className='text-base font-semibold mt-3 mb-2 text-default-700'
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
h6: ({ node: _node, ...props }) => (
|
||||
<h6
|
||||
className='text-sm font-semibold mt-3 mb-2 text-default-600'
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
p: ({ node: _node, ...props }) => (
|
||||
<p
|
||||
className='my-3 leading-7 text-default-700 first:mt-0 last:mb-0'
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
p: ({ node: _node, ...props }) => <p className='m-0' {...props} />,
|
||||
a: ({ node: _node, ...props }) => (
|
||||
<a
|
||||
className='text-primary-500 inline-block hover:underline'
|
||||
className='text-primary font-medium hover:text-primary-600 underline decoration-primary/30 hover:decoration-primary transition-colors'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
ul: ({ node: _node, ...props }) => (
|
||||
<ul className='list-disc list-inside' {...props} />
|
||||
),
|
||||
ol: ({ node: _node, ...props }) => (
|
||||
<ol className='list-decimal list-inside' {...props} />
|
||||
),
|
||||
blockquote: ({ node: _node, ...props }) => (
|
||||
<blockquote
|
||||
className='border-l-4 border-default-300 pl-4 italic'
|
||||
<ul
|
||||
className='my-3 ml-6 space-y-2 list-disc marker:text-primary'
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
code: ({ node: _node, ...props }) => (
|
||||
<code className='bg-default-100 p-1 rounded text-xs' {...props} />
|
||||
ol: ({ node: _node, ...props }) => (
|
||||
<ol
|
||||
className='my-3 ml-6 space-y-2 list-decimal marker:text-primary marker:font-semibold'
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
li: ({ node: _node, ...props }) => (
|
||||
<li
|
||||
className='leading-7 text-default-700 pl-2'
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
blockquote: ({ node: _node, ...props }) => (
|
||||
<blockquote
|
||||
className='my-4 pl-4 pr-4 py-2 border-l-4 border-primary/50 bg-primary/5 rounded-r-lg italic text-default-600'
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
pre: ({ node: _node, ...props }) => (
|
||||
<pre
|
||||
className='my-4 p-4 bg-default-100 dark:bg-default-50 rounded-xl overflow-x-auto text-sm border border-default-200 shadow-sm'
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
code: ({ node: _node, inline, ...props }: any) => {
|
||||
if (inline) {
|
||||
return (
|
||||
<code
|
||||
className='px-1.5 py-0.5 mx-0.5 bg-primary/10 text-primary-700 dark:text-primary-600 rounded text-sm font-mono border border-primary/20'
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<code
|
||||
className='text-sm font-mono text-default-800'
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
img: ({ node: _node, ...props }) => (
|
||||
<img
|
||||
className='max-w-full h-auto rounded-lg my-4 shadow-md hover:shadow-xl transition-shadow border border-default-200'
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
hr: ({ node: _node, ...props }) => (
|
||||
<hr
|
||||
className='my-8 border-0 h-px bg-gradient-to-r from-transparent via-default-300 to-transparent'
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
table: ({ node: _node, ...props }) => (
|
||||
<div className='my-4 overflow-x-auto rounded-lg border border-default-200 shadow-sm'>
|
||||
<table
|
||||
className='min-w-full divide-y divide-default-200'
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
thead: ({ node: _node, ...props }) => (
|
||||
<thead
|
||||
className='bg-default-100'
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
tbody: ({ node: _node, ...props }) => (
|
||||
<tbody
|
||||
className='divide-y divide-default-200 bg-white dark:bg-default-50'
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
tr: ({ node: _node, ...props }) => (
|
||||
<tr
|
||||
className='hover:bg-default-50 transition-colors'
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
th: ({ node: _node, ...props }) => (
|
||||
<th
|
||||
className='px-4 py-3 text-left text-xs font-semibold text-default-700 uppercase tracking-wider'
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
td: ({ node: _node, ...props }) => (
|
||||
<td
|
||||
className='px-4 py-3 text-sm text-default-700'
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
strong: ({ node: _node, ...props }) => (
|
||||
<strong
|
||||
className='font-bold text-default-900'
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
em: ({ node: _node, ...props }) => (
|
||||
<em
|
||||
className='italic text-default-700'
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
del: ({ node: _node, ...props }) => (
|
||||
<del
|
||||
className='line-through text-default-500'
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from '@heroui/modal';
|
||||
import { Button } from '@heroui/button';
|
||||
import { Chip } from '@heroui/chip';
|
||||
import { Avatar } from '@heroui/avatar';
|
||||
import { Tooltip } from '@heroui/tooltip';
|
||||
import { Spinner } from '@heroui/spinner';
|
||||
import { IoMdCheckmarkCircle, IoMdOpen, IoMdDownload } from 'react-icons/io';
|
||||
import { MdUpdate } from 'react-icons/md';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
import { PluginStoreItem } from '@/types/plugin-store';
|
||||
import { InstallStatus } from '@/components/display_card/plugin_store_card';
|
||||
import TailwindMarkdown from '@/components/tailwind_markdown';
|
||||
|
||||
interface PluginDetailModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
plugin: PluginStoreItem | null;
|
||||
installStatus?: InstallStatus;
|
||||
installedVersion?: string;
|
||||
onInstall?: () => void;
|
||||
}
|
||||
|
||||
/** 提取作者头像 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 {
|
||||
return `https://api.iowen.cn/favicon/${url.hostname}.png`;
|
||||
}
|
||||
} catch {
|
||||
// 忽略解析错误
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** 提取 GitHub 仓库信息 */
|
||||
function extractGitHubRepo (url?: string): { owner: string; repo: string; } | null {
|
||||
if (!url) return null;
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
if (urlObj.hostname === 'github.com' || urlObj.hostname === 'www.github.com') {
|
||||
const parts = urlObj.pathname.split('/').filter(Boolean);
|
||||
if (parts.length >= 2) {
|
||||
return { owner: parts[0], repo: parts[1] };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 忽略解析错误
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 从 GitHub API 获取 README */
|
||||
async function fetchGitHubReadme (owner: string, repo: string): Promise<string> {
|
||||
const response = await fetch(`https://api.github.com/repos/${owner}/${repo}/readme`, {
|
||||
headers: {
|
||||
Accept: 'application/vnd.github.v3.raw',
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch README');
|
||||
}
|
||||
return response.text();
|
||||
}
|
||||
|
||||
/** 清理 README 中的 HTML 标签,保留 Markdown */
|
||||
function cleanReadmeHtml (content: string): string {
|
||||
// 移除 HTML 注释
|
||||
let cleaned = content.replace(/<!--[\s\S]*?-->/g, '');
|
||||
|
||||
// 保留常见的 Markdown 友好的 HTML 标签(img, br),其他的移除标签但保留内容
|
||||
// 移除 style 和 script 标签及其内容
|
||||
cleaned = cleaned.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '');
|
||||
cleaned = cleaned.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '');
|
||||
|
||||
// 将其他 HTML 标签替换为空格或换行(保留内容)
|
||||
// 保留 img 标签(转为 markdown)- 尝试提取 alt 和 src 属性
|
||||
cleaned = cleaned.replace(/<img[^>]*\\bsrc=["']([^"']+)["'][^>]*\\balt=["']([^"']+)["'][^>]*>/gi, '');
|
||||
cleaned = cleaned.replace(/<img[^>]*\\balt=["']([^"']+)["'][^>]*\\bsrc=["']([^"']+)["'][^>]*>/gi, '');
|
||||
cleaned = cleaned.replace(/<img[^>]+src=["']([^"']+)["'][^>]*>/gi, '');
|
||||
|
||||
// 移除其他 HTML 标签,但保留内容
|
||||
cleaned = cleaned.replace(/<\/?[^>]+(>|$)/g, '');
|
||||
|
||||
// 清理多余的空行(超过2个连续换行)
|
||||
cleaned = cleaned.replace(/\n{3,}/g, '\n\n');
|
||||
|
||||
return cleaned.trim();
|
||||
}
|
||||
|
||||
export default function PluginDetailModal ({
|
||||
isOpen,
|
||||
onClose,
|
||||
plugin,
|
||||
installStatus = 'not-installed',
|
||||
installedVersion,
|
||||
onInstall,
|
||||
}: PluginDetailModalProps) {
|
||||
const [readme, setReadme] = useState<string>('');
|
||||
const [readmeLoading, setReadmeLoading] = useState(false);
|
||||
const [readmeError, setReadmeError] = useState(false);
|
||||
|
||||
// 获取 GitHub 仓库信息(需要在 hooks 之前计算)
|
||||
const githubRepo = plugin ? extractGitHubRepo(plugin.homepage) : null;
|
||||
|
||||
// 当模态框打开且有 GitHub 链接时,获取 README
|
||||
useEffect(() => {
|
||||
if (!isOpen || !githubRepo) {
|
||||
setReadme('');
|
||||
setReadmeError(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadReadme = async () => {
|
||||
setReadmeLoading(true);
|
||||
setReadmeError(false);
|
||||
try {
|
||||
const content = await fetchGitHubReadme(githubRepo.owner, githubRepo.repo);
|
||||
// 清理 HTML 标签后再设置
|
||||
setReadme(cleanReadmeHtml(content));
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch README:', error);
|
||||
setReadmeError(true);
|
||||
} finally {
|
||||
setReadmeLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadReadme();
|
||||
}, [isOpen, githubRepo?.owner, githubRepo?.repo]);
|
||||
|
||||
if (!plugin) return null;
|
||||
|
||||
const { name, version, author, description, tags, homepage, downloadUrl, minVersion } = plugin;
|
||||
const avatarUrl = getAuthorAvatar(homepage, downloadUrl) || `https://avatar.vercel.sh/${encodeURIComponent(name)}`;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size='4xl'
|
||||
scrollBehavior='inside'
|
||||
classNames={{
|
||||
backdrop: 'z-[200]',
|
||||
wrapper: 'z-[200]',
|
||||
}}
|
||||
>
|
||||
<ModalContent>
|
||||
{(onModalClose) => (
|
||||
<>
|
||||
<ModalHeader className='flex flex-col gap-3 pb-2'>
|
||||
{/* 插件头部信息 */}
|
||||
<div className='flex items-start gap-4'>
|
||||
<Avatar
|
||||
src={avatarUrl}
|
||||
name={author || '?'}
|
||||
size='lg'
|
||||
isBordered
|
||||
color='primary'
|
||||
radius='lg'
|
||||
className='flex-shrink-0'
|
||||
/>
|
||||
<div className='flex-1 min-w-0'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<h2 className='text-2xl font-bold text-default-900'>{name}</h2>
|
||||
{homepage && (
|
||||
<Tooltip content='访问项目主页'>
|
||||
<Button
|
||||
isIconOnly
|
||||
size='sm'
|
||||
variant='flat'
|
||||
color='primary'
|
||||
as='a'
|
||||
href={homepage}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
<IoMdOpen size={18} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<p className='text-sm text-default-500 mt-1'>
|
||||
by <span className='font-medium'>{author || '未知作者'}</span>
|
||||
</p>
|
||||
|
||||
{/* 标签和版本信息 */}
|
||||
<div className='flex items-center gap-2 mt-2 flex-wrap'>
|
||||
<Chip size='sm' color='primary' variant='flat'>
|
||||
v{version}
|
||||
</Chip>
|
||||
{tags?.map((tag) => (
|
||||
<Chip
|
||||
key={tag}
|
||||
size='sm'
|
||||
variant='flat'
|
||||
className='bg-default-100 text-default-600'
|
||||
>
|
||||
{tag}
|
||||
</Chip>
|
||||
))}
|
||||
{installStatus === 'update-available' && installedVersion && (
|
||||
<Chip
|
||||
size='sm'
|
||||
color='warning'
|
||||
variant='shadow'
|
||||
className='animate-pulse'
|
||||
>
|
||||
可更新
|
||||
</Chip>
|
||||
)}
|
||||
{installStatus === 'installed' && (
|
||||
<Chip
|
||||
size='sm'
|
||||
color='success'
|
||||
variant='flat'
|
||||
startContent={<IoMdCheckmarkCircle size={14} />}
|
||||
>
|
||||
已安装
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody className='gap-4'>
|
||||
{/* 插件描述 */}
|
||||
<div>
|
||||
<h3 className='text-sm font-semibold text-default-700 mb-2'>插件描述</h3>
|
||||
<p className='text-sm text-default-600 leading-relaxed'>
|
||||
{description || '暂无描述'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 插件信息 */}
|
||||
<div>
|
||||
<h3 className='text-sm font-semibold text-default-700 mb-3'>插件信息</h3>
|
||||
<div className='grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm'>
|
||||
<div className='flex justify-between items-center'>
|
||||
<span className='text-default-500'>最新版本:</span>
|
||||
<span className='font-medium text-default-900'>v{version}</span>
|
||||
</div>
|
||||
{installedVersion && (
|
||||
<div className='flex justify-between items-center'>
|
||||
<span className='text-default-500'>已安装版本:</span>
|
||||
<span className='font-medium text-default-900'>v{installedVersion}</span>
|
||||
</div>
|
||||
)}
|
||||
{minVersion && (
|
||||
<div className='flex justify-between items-center'>
|
||||
<span className='text-default-500'>最低要求版本:</span>
|
||||
<span className='font-medium text-default-900'>v{minVersion}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className='flex justify-between items-center'>
|
||||
<span className='text-default-500'>插件 ID:</span>
|
||||
<span className='font-mono text-xs text-default-900'>{plugin.id}</span>
|
||||
</div>
|
||||
{downloadUrl && (
|
||||
<div className='flex justify-between items-center'>
|
||||
<span className='text-default-500'>下载地址:</span>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='flat'
|
||||
color='primary'
|
||||
as='a'
|
||||
href={downloadUrl}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
startContent={<IoMdDownload size={14} />}
|
||||
>
|
||||
下载插件
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* GitHub README 显示 */}
|
||||
{githubRepo && (
|
||||
<>
|
||||
<div className='mt-2'>
|
||||
<h3 className='text-sm font-semibold text-default-700 mb-3'>详情</h3>
|
||||
{readmeLoading && (
|
||||
<div className='flex justify-center items-center py-12'>
|
||||
<Spinner size='lg' />
|
||||
</div>
|
||||
)}
|
||||
{readmeError && (
|
||||
<div className='text-center py-8'>
|
||||
<p className='text-sm text-default-500 mb-3'>
|
||||
无法加载 README
|
||||
</p>
|
||||
<Button
|
||||
color='primary'
|
||||
variant='flat'
|
||||
as='a'
|
||||
href={homepage}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
startContent={<IoMdOpen />}
|
||||
>
|
||||
在 GitHub 查看
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{!readmeLoading && !readmeError && readme && (
|
||||
<div className='rounded-lg border border-default-200 p-4 bg-default-50'>
|
||||
<TailwindMarkdown content={readme} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button variant='light' onPress={onModalClose}>
|
||||
关闭
|
||||
</Button>
|
||||
{installStatus === 'installed'
|
||||
? (
|
||||
<Button
|
||||
color='success'
|
||||
variant='flat'
|
||||
startContent={<IoMdCheckmarkCircle size={18} />}
|
||||
isDisabled
|
||||
>
|
||||
已安装
|
||||
</Button>
|
||||
)
|
||||
: installStatus === 'update-available'
|
||||
? (
|
||||
<Button
|
||||
color='warning'
|
||||
variant='shadow'
|
||||
startContent={<MdUpdate size={18} />}
|
||||
onPress={() => {
|
||||
onInstall?.();
|
||||
onModalClose();
|
||||
}}
|
||||
>
|
||||
更新到 v{version}
|
||||
</Button>
|
||||
)
|
||||
: (
|
||||
<Button
|
||||
color='primary'
|
||||
variant='shadow'
|
||||
startContent={<IoMdDownload size={18} />}
|
||||
onPress={() => {
|
||||
onInstall?.();
|
||||
onModalClose();
|
||||
}}
|
||||
>
|
||||
立即安装
|
||||
</Button>
|
||||
)}
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -9,10 +9,12 @@ import { IoMdRefresh, IoMdSearch, IoMdSettings } from 'react-icons/io';
|
||||
import clsx from 'clsx';
|
||||
import { EventSourcePolyfill } from 'event-source-polyfill';
|
||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
import PluginStoreCard, { InstallStatus } from '@/components/display_card/plugin_store_card';
|
||||
import PluginManager, { PluginItem } from '@/controllers/plugin_manager';
|
||||
import MirrorSelectorModal from '@/components/mirror_selector_modal';
|
||||
import PluginDetailModal from '@/pages/dashboard/plugin_detail_modal';
|
||||
import { PluginStoreItem } from '@/types/plugin-store';
|
||||
import useDialog from '@/hooks/use-dialog';
|
||||
import key from '@/const/key';
|
||||
@@ -42,6 +44,7 @@ export default function PluginStorePage () {
|
||||
const [pluginManagerNotFound, setPluginManagerNotFound] = useState(false);
|
||||
const dialog = useDialog();
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
// 快捷键支持: Ctrl+F 聚焦搜索框
|
||||
useEffect(() => {
|
||||
@@ -79,6 +82,10 @@ export default function PluginStorePage () {
|
||||
const [pendingInstallPlugin, setPendingInstallPlugin] = useState<PluginStoreItem | null>(null);
|
||||
const [selectedDownloadMirror, setSelectedDownloadMirror] = useState<string | undefined>(undefined);
|
||||
|
||||
// 插件详情弹窗状态
|
||||
const [detailModalOpen, setDetailModalOpen] = useState(false);
|
||||
const [selectedPlugin, setSelectedPlugin] = useState<PluginStoreItem | null>(null);
|
||||
|
||||
const loadPlugins = async (forceRefresh: boolean = false) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -100,6 +107,22 @@ export default function PluginStorePage () {
|
||||
loadPlugins();
|
||||
}, [currentStoreSource]);
|
||||
|
||||
// 处理 URL 参数中的插件 ID,自动打开详情
|
||||
useEffect(() => {
|
||||
const pluginId = searchParams.get('pluginId');
|
||||
if (pluginId && plugins.length > 0 && !detailModalOpen) {
|
||||
// 查找对应的插件
|
||||
const targetPlugin = plugins.find(p => p.id === pluginId);
|
||||
if (targetPlugin) {
|
||||
setSelectedPlugin(targetPlugin);
|
||||
setDetailModalOpen(true);
|
||||
// 移除 URL 参数(可选)
|
||||
// searchParams.delete('pluginId');
|
||||
// setSearchParams(searchParams);
|
||||
}
|
||||
}
|
||||
}, [plugins, searchParams, detailModalOpen]);
|
||||
|
||||
// 按标签分类和搜索
|
||||
const categorizedPlugins = useMemo(() => {
|
||||
let filtered = plugins;
|
||||
@@ -383,6 +406,10 @@ export default function PluginStorePage () {
|
||||
installStatus={installInfo.status}
|
||||
installedVersion={installInfo.installedVersion}
|
||||
onInstall={() => { handleInstall(plugin); }}
|
||||
onViewDetail={() => {
|
||||
setSelectedPlugin(plugin);
|
||||
setDetailModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -421,6 +448,28 @@ export default function PluginStorePage () {
|
||||
type='file'
|
||||
/>
|
||||
|
||||
{/* 插件详情弹窗 */}
|
||||
<PluginDetailModal
|
||||
isOpen={detailModalOpen}
|
||||
onClose={() => {
|
||||
setDetailModalOpen(false);
|
||||
setSelectedPlugin(null);
|
||||
// 清除 URL 参数
|
||||
if (searchParams.has('pluginId')) {
|
||||
searchParams.delete('pluginId');
|
||||
setSearchParams(searchParams);
|
||||
}
|
||||
}}
|
||||
plugin={selectedPlugin}
|
||||
installStatus={selectedPlugin ? getPluginInstallInfo(selectedPlugin).status : 'not-installed'}
|
||||
installedVersion={selectedPlugin ? getPluginInstallInfo(selectedPlugin).installedVersion : undefined}
|
||||
onInstall={() => {
|
||||
if (selectedPlugin) {
|
||||
handleInstall(selectedPlugin);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 插件下载进度条全局居中样式 */}
|
||||
{installProgress.show && (
|
||||
<div className='fixed inset-0 flex items-center justify-center z-[9999] animate-in fade-in duration-300'>
|
||||
|
||||
Reference in New Issue
Block a user