为插件接口添加主页字段并优化展示组件

本次更新在 PluginPackageJson 接口及相关类型中新增了一个可选的 `homepage` 字段,允许插件指定其主页 URL。插件展示组件已更新,新增了一个指向主页的 GitHub 链接按钮,以提升用户对插件资源的访问便捷性。此外,PluginConfigModal 中新增了一个问题反馈按钮,该按钮直接链接到插件的主页,从而优化了用户支持与反馈机制。
This commit is contained in:
Qiao 2026-01-31 15:48:18 +08:00
parent 71f8504849
commit 4e5dddde90
8 changed files with 113 additions and 56 deletions

View File

@ -13,6 +13,7 @@ export interface PluginPackageJson {
main?: string; main?: string;
description?: string; description?: string;
author?: string; author?: string;
homepage?: string;
} }
// ==================== 插件配置 Schema ==================== // ==================== 插件配置 Schema ====================

View File

@ -72,6 +72,7 @@ export const GetPluginListHandler: RequestHandler = async (_req, res) => {
version: string; version: string;
description: string; description: string;
author: string; author: string;
homepage?: string;
status: string; status: string;
hasConfig: boolean; hasConfig: boolean;
hasPages: boolean; hasPages: boolean;
@ -109,6 +110,7 @@ export const GetPluginListHandler: RequestHandler = async (_req, res) => {
version: p.version || '0.0.0', version: p.version || '0.0.0',
description: p.packageJson?.description || '', description: p.packageJson?.description || '',
author: p.packageJson?.author || '', author: p.packageJson?.author || '',
homepage: p.packageJson?.homepage,
status, status,
hasConfig: !!(p.runtime.module?.plugin_config_schema || p.runtime.module?.plugin_config_ui), hasConfig: !!(p.runtime.module?.plugin_config_schema || p.runtime.module?.plugin_config_ui),
hasPages hasPages

View File

@ -22,6 +22,7 @@ export interface DisplayCardProps {
const DisplayCardContainer: React.FC<ContainerProps> = ({ const DisplayCardContainer: React.FC<ContainerProps> = ({
title: _title, title: _title,
tag,
action, action,
enableSwitch, enableSwitch,
children, children,
@ -45,7 +46,8 @@ const DisplayCardContainer: React.FC<ContainerProps> = ({
</span> </span>
</div> </div>
</div> </div>
<div className='flex-shrink-0'>{enableSwitch}</div> {tag && <div className='flex-shrink-0'>{tag}</div>}
{enableSwitch && <div className='flex-shrink-0'>{enableSwitch}</div>}
</CardHeader> </CardHeader>
<CardBody className='px-4 py-2 text-sm text-default-600'>{children}</CardBody> <CardBody className='px-4 py-2 text-sm text-default-600'>{children}</CardBody>
<CardFooter className='px-4 pb-4 pt-2'>{action}</CardFooter> <CardFooter className='px-4 pb-4 pt-2'>{action}</CardFooter>

View File

@ -1,6 +1,7 @@
import { Button } from '@heroui/button'; import { Button } from '@heroui/button';
import { Switch } from '@heroui/switch'; import { Switch } from '@heroui/switch';
import { Chip } from '@heroui/chip'; import { Chip } from '@heroui/chip';
import { Tooltip } from '@heroui/tooltip';
import { useState } from 'react'; import { useState } from 'react';
import { MdDeleteForever, MdSettings } from 'react-icons/md'; import { MdDeleteForever, MdSettings } from 'react-icons/md';
@ -41,28 +42,27 @@ const PluginDisplayCard: React.FC<PluginDisplayCardProps> = ({
<DisplayCardContainer <DisplayCardContainer
className='w-full max-w-[420px]' className='w-full max-w-[420px]'
action={ action={
<div className='flex flex-col gap-2 w-full'> <div className='flex gap-2 w-full'>
<div className='flex gap-2 w-full'> <Tooltip content="卸载">
<Button <Button
fullWidth isIconOnly
radius='full' radius='full'
size='sm' size='sm'
variant='flat' 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' className='bg-default-100 dark:bg-default-50 text-default-600 hover:bg-danger/20 hover:text-danger transition-colors'
startContent={<MdDeleteForever size={16} />}
onPress={handleUninstall} onPress={handleUninstall}
isDisabled={processing} isDisabled={processing}
> >
<MdDeleteForever size={16} />
</Button> </Button>
</div> </Tooltip>
{hasConfig && ( {hasConfig && (
<Button <Button
fullWidth fullWidth
radius='full' radius='full'
size='sm' size='sm'
variant='flat' variant='flat'
className='bg-default-100 dark:bg-default-50 text-default-600 font-medium hover:bg-secondary/20 hover:text-secondary transition-colors' className='flex-1 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} />} startContent={<MdSettings size={16} />}
onPress={onConfig} onPress={onConfig}
> >

View File

@ -1,7 +1,9 @@
import { Button } from '@heroui/button'; import { Button } from '@heroui/button';
import { Chip } from '@heroui/chip'; import { Chip } from '@heroui/chip';
import { Tooltip } from '@heroui/tooltip';
import { useState } from 'react'; import { useState } from 'react';
import { IoMdDownload, IoMdRefresh, IoMdCheckmarkCircle } from 'react-icons/io'; import { IoMdDownload, IoMdRefresh, IoMdCheckmarkCircle } from 'react-icons/io';
import { FaGithub } from 'react-icons/fa';
import DisplayCardContainer from './container'; import DisplayCardContainer from './container';
import { PluginStoreItem } from '@/types/plugin-store'; import { PluginStoreItem } from '@/types/plugin-store';
@ -20,7 +22,7 @@ const PluginStoreCard: React.FC<PluginStoreCardProps> = ({
onInstall, onInstall,
installStatus = 'not-installed', installStatus = 'not-installed',
}) => { }) => {
const { name, version, author, description, tags, id } = data; const { name, version, author, description, tags, id, homepage } = data;
const [processing, setProcessing] = useState(false); const [processing, setProcessing] = useState(false);
const handleInstall = () => { const handleInstall = () => {
@ -60,6 +62,19 @@ const PluginStoreCard: React.FC<PluginStoreCardProps> = ({
title={name} title={name}
tag={ tag={
<div className="ml-auto flex items-center gap-1"> <div className="ml-auto flex items-center gap-1">
{homepage && (
<Tooltip content="仓库主页">
<Button
isIconOnly
size='sm'
variant='light'
className='min-w-6 w-6 h-6 text-default-500 hover:text-default-700'
onPress={() => window.open(homepage, '_blank')}
>
<FaGithub size={14} />
</Button>
</Tooltip>
)}
{installStatus === 'installed' && ( {installStatus === 'installed' && (
<Chip <Chip
color="success" color="success"
@ -104,51 +119,40 @@ const PluginStoreCard: React.FC<PluginStoreCardProps> = ({
</Button> </Button>
} }
> >
<div className='grid grid-cols-2 gap-3'> <div className='flex flex-col gap-2 h-[120px]'>
<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'> <div className='flex items-center gap-2 text-xs text-default-500 dark:text-white/50'>
<span>: <span className='text-default-700 dark:text-white/80'>{author || '未知'}</span></span>
</span> <span className='text-default-300'>·</span>
<div className='text-sm font-medium text-default-700 dark:text-white/90 truncate'> <Tooltip content={id}>
{author || '未知'} <span className='truncate max-w-[150px]'>{id}</span>
</div> </Tooltip>
</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'> {/* 描述 */}
<div className='flex-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl'>
</span> <div className='text-sm text-default-700 dark:text-white/90 break-words line-clamp-2'>
<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 || '暂无描述'} {description || '暂无描述'}
</div> </div>
</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'> <div className='flex flex-wrap gap-1 min-h-[24px]'>
{tags && tags.length > 0 ? (
</span> tags.slice(0, 3).map((tag, index) => (
<div className='text-sm font-medium text-default-700 dark:text-white/90 break-words line-clamp-2 h-10 overflow-hidden'> <Chip
{id || '包名'} key={index}
</div> size='sm'
</div> variant='flat'
)} className='text-xs'
{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'> {tag}
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'> </Chip>
))
</span> ) : (
<div className='text-sm font-medium text-default-700 dark:text-white/90 truncate'> <span className='text-xs text-default-400'></span>
{tags.slice(0, 2).join(' · ')} )}
</div> </div>
</div>
)}
</div> </div>
</DisplayCardContainer> </DisplayCardContainer>
); );

View File

@ -16,6 +16,8 @@ export interface PluginItem {
description: string; description: string;
/** 作者 */ /** 作者 */
author: string; author: string;
/** 主页链接 */
homepage?: string;
/** 状态: active-运行中, disabled-已禁用, stopped-已停止 */ /** 状态: active-运行中, disabled-已禁用, stopped-已停止 */
status: PluginStatus; status: PluginStatus;
/** 是否有配置项 */ /** 是否有配置项 */

View File

@ -1,5 +1,5 @@
import { Button } from '@heroui/button'; import { Button } from '@heroui/button';
import { useEffect, useState, useRef } from 'react'; import { useEffect, useState, useRef, useMemo } from 'react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { IoMdRefresh } from 'react-icons/io'; import { IoMdRefresh } from 'react-icons/io';
import { FiUpload } from 'react-icons/fi'; import { FiUpload } from 'react-icons/fi';
@ -10,9 +10,11 @@ import PluginDisplayCard from '@/components/display_card/plugin_card';
import PluginManager, { PluginItem } from '@/controllers/plugin_manager'; import PluginManager, { PluginItem } from '@/controllers/plugin_manager';
import useDialog from '@/hooks/use-dialog'; import useDialog from '@/hooks/use-dialog';
import PluginConfigModal from '@/pages/dashboard/plugin_config_modal'; import PluginConfigModal from '@/pages/dashboard/plugin_config_modal';
import { PluginStoreItem } from '@/types/plugin-store';
export default function PluginPage () { export default function PluginPage () {
const [plugins, setPlugins] = useState<PluginItem[]>([]); const [plugins, setPlugins] = useState<PluginItem[]>([]);
const [storePlugins, setStorePlugins] = useState<PluginStoreItem[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [pluginManagerNotFound, setPluginManagerNotFound] = useState(false); const [pluginManagerNotFound, setPluginManagerNotFound] = useState(false);
const dialog = useDialog(); const dialog = useDialog();
@ -25,7 +27,11 @@ export default function PluginPage () {
setLoading(true); setLoading(true);
setPluginManagerNotFound(false); setPluginManagerNotFound(false);
try { try {
const listResult = await PluginManager.getPluginList(); // 并行加载本地插件列表和商店插件列表
const [listResult, storeResult] = await Promise.all([
PluginManager.getPluginList(),
PluginManager.getPluginStoreList().catch(() => ({ plugins: [] }))
]);
if (listResult.pluginManagerNotFound) { if (listResult.pluginManagerNotFound) {
setPluginManagerNotFound(true); setPluginManagerNotFound(true);
@ -33,6 +39,7 @@ export default function PluginPage () {
} else { } else {
setPlugins(listResult.plugins); setPlugins(listResult.plugins);
} }
setStorePlugins(storeResult.plugins || []);
} catch (e: any) { } catch (e: any) {
toast.error(e.message); toast.error(e.message);
} finally { } finally {
@ -40,6 +47,25 @@ export default function PluginPage () {
} }
}; };
// 创建一个 Map 用于快速查找商店插件的 homepage
const storeHomepageMap = useMemo(() => {
const map = new Map<string, string>();
for (const plugin of storePlugins) {
if (plugin.homepage) {
map.set(plugin.id, plugin.homepage);
}
}
return map;
}, [storePlugins]);
// 合并本地插件和商店数据中的 homepage
const pluginsWithHomepage = useMemo(() => {
return plugins.map(plugin => ({
...plugin,
homepage: plugin.homepage || storeHomepageMap.get(plugin.id)
}));
}, [plugins, storeHomepageMap]);
useEffect(() => { useEffect(() => {
loadPlugins(); loadPlugins();
}, []); }, []);
@ -172,6 +198,7 @@ export default function PluginPage () {
isOpen={isOpen} isOpen={isOpen}
onOpenChange={onOpenChange} onOpenChange={onOpenChange}
pluginId={currentPluginId} pluginId={currentPluginId}
homepage={storeHomepageMap.get(currentPluginId)}
/> />
<div className='flex mb-6 items-center gap-4'> <div className='flex mb-6 items-center gap-4'>
@ -211,11 +238,11 @@ export default function PluginPage () {
plugins plugins
</p> </p>
</div> </div>
) : plugins.length === 0 ? ( ) : pluginsWithHomepage.length === 0 ? (
<div className="text-default-400"></div> <div className="text-default-400"></div>
) : ( ) : (
<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-x-2 gap-y-4'>
{plugins.map(plugin => ( {pluginsWithHomepage.map(plugin => (
<PluginDisplayCard <PluginDisplayCard
key={plugin.id} key={plugin.id}
data={plugin} data={plugin}

View File

@ -3,9 +3,11 @@ import { Button } from '@heroui/button';
import { Input } from '@heroui/input'; import { Input } from '@heroui/input';
import { Select, SelectItem } from '@heroui/select'; import { Select, SelectItem } from '@heroui/select';
import { Switch } from '@heroui/switch'; import { Switch } from '@heroui/switch';
import { Tooltip } from '@heroui/tooltip';
import { useEffect, useState, useRef, useCallback } from 'react'; import { useEffect, useState, useRef, useCallback } from 'react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { EventSourcePolyfill } from 'event-source-polyfill'; import { EventSourcePolyfill } from 'event-source-polyfill';
import { IoMdOpen } from 'react-icons/io';
import PluginManager, { PluginConfigSchemaItem } from '@/controllers/plugin_manager'; import PluginManager, { PluginConfigSchemaItem } from '@/controllers/plugin_manager';
import key from '@/const/key'; import key from '@/const/key';
@ -14,6 +16,8 @@ interface Props {
onOpenChange: () => void; onOpenChange: () => void;
/** 插件包名 (id) */ /** 插件包名 (id) */
pluginId: string; pluginId: string;
/** 插件主页 URL */
homepage?: string;
} }
/** Schema 更新事件类型 */ /** Schema 更新事件类型 */
@ -25,7 +29,7 @@ interface SchemaUpdateEvent {
afterKey?: string; afterKey?: string;
} }
export default function PluginConfigModal ({ isOpen, onOpenChange, pluginId }: Props) { export default function PluginConfigModal ({ isOpen, onOpenChange, pluginId, homepage }: Props) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [schema, setSchema] = useState<PluginConfigSchemaItem[]>([]); const [schema, setSchema] = useState<PluginConfigSchemaItem[]>([]);
const [config, setConfig] = useState<Record<string, unknown>>({}); const [config, setConfig] = useState<Record<string, unknown>>({});
@ -373,6 +377,21 @@ export default function PluginConfigModal ({ isOpen, onOpenChange, pluginId }: P
)} )}
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
{homepage && (
<Tooltip content="反馈问题">
<Button
isIconOnly
variant="light"
onPress={() => {
const issueUrl = homepage.includes('github.com') ? `${homepage}/issues` : homepage;
window.open(issueUrl, '_blank');
}}
className="mr-auto"
>
<IoMdOpen size={18} />
</Button>
</Tooltip>
)}
<Button color="danger" variant="light" onPress={onClose}> <Button color="danger" variant="light" onPress={onClose}>
Close Close
</Button> </Button>