mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-02-04 14:41:14 +00:00
为插件接口添加主页字段并优化展示组件
本次更新在 PluginPackageJson 接口及相关类型中新增了一个可选的 `homepage` 字段,允许插件指定其主页 URL。插件展示组件已更新,新增了一个指向主页的 GitHub 链接按钮,以提升用户对插件资源的访问便捷性。此外,PluginConfigModal 中新增了一个问题反馈按钮,该按钮直接链接到插件的主页,从而优化了用户支持与反馈机制。
This commit is contained in:
parent
71f8504849
commit
4e5dddde90
@ -13,6 +13,7 @@ export interface PluginPackageJson {
|
|||||||
main?: string;
|
main?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
author?: string;
|
author?: string;
|
||||||
|
homepage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 插件配置 Schema ====================
|
// ==================== 插件配置 Schema ====================
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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;
|
||||||
/** 是否有配置项 */
|
/** 是否有配置项 */
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user