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

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

View File

@@ -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>
);
};

View File

@@ -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>
);
};