feat(webui): 插件商店增加插件详情弹窗并支持通过 url 传递 id 直接打开 (#1615)

* feat(webui): 插件商店增加插件详情弹窗并支持通过 url 传递 id 直接打开

* fix(webui):type check
This commit is contained in:
林小槐
2026-02-11 12:12:06 +08:00
committed by GitHub
parent 37fb2d68d7
commit 718ad2513d
5 changed files with 601 additions and 19 deletions

3
.gitignore vendored
View File

@@ -10,6 +10,9 @@ devconfig/*
!.vscode/extensions.json
.idea/*
# macOS
.DS_Store
# Build
*.db
checkVersion.sh

View File

@@ -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

View File

@@ -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}
/>
),
}}
>

View File

@@ -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, '![$2]($1)');
cleaned = cleaned.replace(/<img[^>]*\\balt=["']([^"']+)["'][^>]*\\bsrc=["']([^"']+)["'][^>]*>/gi, '![$1]($2)');
cleaned = cleaned.replace(/<img[^>]+src=["']([^"']+)["'][^>]*>/gi, '![]($1)');
// 移除其他 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>
);
}

View File

@@ -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'>