perf: add component list in plugin detail dialog

This commit is contained in:
Junyan Qin
2025-10-12 19:57:42 +08:00
parent 4df372052d
commit 5e2f677d0b
15 changed files with 122 additions and 75 deletions

View File

@@ -0,0 +1,46 @@
import { PluginComponent } from '@/app/infra/entities/plugin';
export interface IPluginCardVO {
author: string;
label: string;
name: string;
description: string;
version: string;
enabled: boolean;
priority: number;
install_source: string;
install_info: Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any
status: string;
components: PluginComponent[];
debug: boolean;
}
export class PluginCardVO implements IPluginCardVO {
author: string;
label: string;
name: string;
description: string;
version: string;
enabled: boolean;
priority: number;
debug: boolean;
install_source: string;
install_info: Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any
status: string;
components: PluginComponent[];
constructor(prop: IPluginCardVO) {
this.author = prop.author;
this.label = prop.label;
this.description = prop.description;
this.enabled = prop.enabled;
this.components = prop.components;
this.name = prop.name;
this.priority = prop.priority;
this.status = prop.status;
this.version = prop.version;
this.debug = prop.debug;
this.install_source = prop.install_source;
this.install_info = prop.install_info;
}
}

View File

@@ -0,0 +1,75 @@
import { PluginComponent } from '@/app/infra/entities/plugin';
import { TFunction } from 'i18next';
import { Wrench, AudioWaveform, Hash } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
export default function PluginComponentList({
components,
showComponentName,
showTitle,
useBadge,
t,
}: {
components: PluginComponent[];
showComponentName: boolean;
showTitle: boolean;
useBadge: boolean;
t: TFunction;
}) {
const componentKindCount: Record<string, number> = {};
for (const component of components) {
const kind = component.manifest.manifest.kind;
if (componentKindCount[kind]) {
componentKindCount[kind]++;
} else {
componentKindCount[kind] = 1;
}
}
const kindIconMap: Record<string, React.ReactNode> = {
Tool: <Wrench className="w-5 h-5" />,
EventListener: <AudioWaveform className="w-5 h-5" />,
Command: <Hash className="w-5 h-5" />,
};
const componentKindList = Object.keys(componentKindCount);
return (
<>
{showTitle && <div>{t('plugins.componentsList')}</div>}
{componentKindList.length > 0 && (
<>
{componentKindList.map((kind) => {
return (
<>
{useBadge && (
<Badge variant="outline">
{kindIconMap[kind]}
{showComponentName &&
t('plugins.componentName.' + kind) + ' '}
{componentKindCount[kind]}
</Badge>
)}
{!useBadge && (
<div
key={kind}
className="flex flex-row items-center justify-start gap-[0.2rem]"
>
{kindIconMap[kind]}
{showComponentName &&
t('plugins.componentName.' + kind) + ' '}
{componentKindCount[kind]}
</div>
)}
</>
);
})}
</>
)}
{componentKindList.length === 0 && <div>{t('plugins.noComponents')}</div>}
</>
);
}

View File

@@ -0,0 +1,312 @@
'use client';
import { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
import { PluginCardVO } from '@/app/home/plugins/components/plugin-installed/PluginCardVO';
import PluginCardComponent from '@/app/home/plugins/components/plugin-installed/plugin-card/PluginCardComponent';
import PluginForm from '@/app/home/plugins/components/plugin-installed/plugin-form/PluginForm';
import styles from '@/app/home/plugins/plugins.module.css';
import { httpClient } from '@/app/infra/http/HttpClient';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { useTranslation } from 'react-i18next';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { toast } from 'sonner';
import { useAsyncTask, AsyncTaskStatus } from '@/hooks/useAsyncTask';
export interface PluginInstalledComponentRef {
refreshPluginList: () => void;
}
enum PluginOperationType {
DELETE = 'DELETE',
UPDATE = 'UPDATE',
}
// eslint-disable-next-line react/display-name
const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
(props, ref) => {
const { t } = useTranslation();
const [pluginList, setPluginList] = useState<PluginCardVO[]>([]);
const [modalOpen, setModalOpen] = useState<boolean>(false);
const [selectedPlugin, setSelectedPlugin] = useState<PluginCardVO | null>(
null,
);
const [showOperationModal, setShowOperationModal] = useState(false);
const [operationType, setOperationType] = useState<PluginOperationType>(
PluginOperationType.DELETE,
);
const [targetPlugin, setTargetPlugin] = useState<PluginCardVO | null>(null);
const asyncTask = useAsyncTask({
onSuccess: () => {
const successMessage =
operationType === PluginOperationType.DELETE
? t('plugins.deleteSuccess')
: t('plugins.updateSuccess');
toast.success(successMessage);
setShowOperationModal(false);
getPluginList();
},
onError: () => {
// Error is already handled in the hook state
},
});
useEffect(() => {
initData();
}, []);
function initData() {
getPluginList();
}
function getPluginList() {
httpClient.getPlugins().then((value) => {
setPluginList(
value.plugins.map((plugin) => {
return new PluginCardVO({
author: plugin.manifest.manifest.metadata.author ?? '',
label: extractI18nObject(plugin.manifest.manifest.metadata.label),
description: extractI18nObject(
plugin.manifest.manifest.metadata.description ?? {
en_US: '',
zh_Hans: '',
},
),
debug: plugin.debug,
enabled: plugin.enabled,
name: plugin.manifest.manifest.metadata.name,
version: plugin.manifest.manifest.metadata.version ?? '',
status: plugin.status,
components: plugin.components,
priority: plugin.priority,
install_source: plugin.install_source,
install_info: plugin.install_info,
});
}),
);
});
}
useImperativeHandle(ref, () => ({
refreshPluginList: getPluginList,
}));
function handlePluginClick(plugin: PluginCardVO) {
setSelectedPlugin(plugin);
setModalOpen(true);
}
function handlePluginDelete(plugin: PluginCardVO) {
setTargetPlugin(plugin);
setOperationType(PluginOperationType.DELETE);
setShowOperationModal(true);
asyncTask.reset();
}
function handlePluginUpdate(plugin: PluginCardVO) {
setTargetPlugin(plugin);
setOperationType(PluginOperationType.UPDATE);
setShowOperationModal(true);
asyncTask.reset();
}
function executeOperation() {
if (!targetPlugin) return;
const apiCall =
operationType === PluginOperationType.DELETE
? httpClient.removePlugin(targetPlugin.author, targetPlugin.name)
: httpClient.upgradePlugin(targetPlugin.author, targetPlugin.name);
apiCall
.then((res) => {
asyncTask.startTask(res.task_id);
})
.catch((error) => {
const errorMessage =
operationType === PluginOperationType.DELETE
? t('plugins.deleteError') + error.message
: t('plugins.updateError') + error.message;
toast.error(errorMessage);
});
}
return (
<>
<Dialog
open={showOperationModal}
onOpenChange={(open) => {
if (!open) {
setShowOperationModal(false);
setTargetPlugin(null);
asyncTask.reset();
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
{operationType === PluginOperationType.DELETE
? t('plugins.deleteConfirm')
: t('plugins.updateConfirm')}
</DialogTitle>
</DialogHeader>
<DialogDescription>
{asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (
<div>
{operationType === PluginOperationType.DELETE
? t('plugins.confirmDeletePlugin', {
author: targetPlugin?.author ?? '',
name: targetPlugin?.name ?? '',
})
: t('plugins.confirmUpdatePlugin', {
author: targetPlugin?.author ?? '',
name: targetPlugin?.name ?? '',
})}
</div>
)}
{asyncTask.status === AsyncTaskStatus.RUNNING && (
<div>
{operationType === PluginOperationType.DELETE
? t('plugins.deleting')
: t('plugins.updating')}
</div>
)}
{asyncTask.status === AsyncTaskStatus.ERROR && (
<div>
{operationType === PluginOperationType.DELETE
? t('plugins.deleteError')
: t('plugins.updateError')}
<div className="text-red-500">{asyncTask.error}</div>
</div>
)}
</DialogDescription>
<DialogFooter>
{asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (
<Button
variant="outline"
onClick={() => {
setShowOperationModal(false);
setTargetPlugin(null);
asyncTask.reset();
}}
>
{t('plugins.cancel')}
</Button>
)}
{asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (
<Button
variant={
operationType === PluginOperationType.DELETE
? 'destructive'
: 'default'
}
onClick={() => {
executeOperation();
}}
>
{operationType === PluginOperationType.DELETE
? t('plugins.confirmDelete')
: t('plugins.confirmUpdate')}
</Button>
)}
{asyncTask.status === AsyncTaskStatus.RUNNING && (
<Button
variant={
operationType === PluginOperationType.DELETE
? 'destructive'
: 'default'
}
disabled
>
{operationType === PluginOperationType.DELETE
? t('plugins.deleting')
: t('plugins.updating')}
</Button>
)}
{asyncTask.status === AsyncTaskStatus.ERROR && (
<Button
variant="default"
onClick={() => {
setShowOperationModal(false);
asyncTask.reset();
}}
>
{t('plugins.close')}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
{pluginList.length === 0 ? (
<div className="flex flex-col items-center justify-center text-gray-500 h-[calc(100vh-16rem)] w-full gap-2">
<svg
className="h-[3rem] w-[3rem]"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M7 5C7 2.79086 8.79086 1 11 1C13.2091 1 15 2.79086 15 5H20C20.5523 5 21 5.44772 21 6V10.1707C21 10.4953 20.8424 10.7997 20.5774 10.9872C20.3123 11.1746 19.9728 11.2217 19.6668 11.1135C19.4595 11.0403 19.2355 11 19 11C17.8954 11 17 11.8954 17 13C17 14.1046 17.8954 15 19 15C19.2355 15 19.4595 14.9597 19.6668 14.8865C19.9728 14.7783 20.3123 14.8254 20.5774 15.0128C20.8424 15.2003 21 15.5047 21 15.8293V20C21 20.5523 20.5523 21 20 21H4C3.44772 21 3 20.5523 3 20V6C3 5.44772 3.44772 5 4 5H7ZM11 3C9.89543 3 9 3.89543 9 5C9 5.23554 9.0403 5.45952 9.11355 5.66675C9.22172 5.97282 9.17461 6.31235 8.98718 6.57739C8.79974 6.84243 8.49532 7 8.17071 7H5V19H19V17C16.7909 17 15 15.2091 15 13C15 10.7909 16.7909 9 19 9V7H13.8293C13.5047 7 13.2003 6.84243 13.0128 6.57739C12.8254 6.31235 12.7783 5.97282 12.8865 5.66675C12.9597 5.45952 13 5.23555 13 5C13 3.89543 12.1046 3 11 3Z"></path>
</svg>
<div className="text-lg mb-2">{t('plugins.noPluginInstalled')}</div>
</div>
) : (
<div className={`${styles.pluginListContainer}`}>
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
<DialogContent className="w-[700px] max-h-[80vh] p-0 flex flex-col">
<DialogHeader className="px-6 pt-6 pb-2">
<DialogTitle>{t('plugins.pluginConfig')}</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto px-6">
{selectedPlugin && (
<PluginForm
pluginAuthor={selectedPlugin.author}
pluginName={selectedPlugin.name}
onFormSubmit={(timeout?: number) => {
setModalOpen(false);
if (timeout) {
setTimeout(() => {
getPluginList();
}, timeout);
} else {
getPluginList();
}
}}
onFormCancel={() => {
setModalOpen(false);
}}
/>
)}
</div>
</DialogContent>
</Dialog>
{pluginList.map((vo, index) => {
return (
<div key={index}>
<PluginCardComponent
cardVO={vo}
onCardClick={() => handlePluginClick(vo)}
onDeleteClick={() => handlePluginDelete(vo)}
onUpgradeClick={() => handlePluginUpdate(vo)}
/>
</div>
);
})}
</div>
)}
</>
);
},
);
export default PluginInstalledComponent;

View File

@@ -0,0 +1,184 @@
import { PluginCardVO } from '@/app/home/plugins/components/plugin-installed/PluginCardVO';
import { useState } from 'react';
import { Badge } from '@/components/ui/badge';
import { useTranslation } from 'react-i18next';
import { BugIcon, ExternalLink, Ellipsis, Trash, ArrowUp } from 'lucide-react';
import { getCloudServiceClientSync } from '@/app/infra/http';
import { httpClient } from '@/app/infra/http/HttpClient';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList';
export default function PluginCardComponent({
cardVO,
onCardClick,
onDeleteClick,
onUpgradeClick,
}: {
cardVO: PluginCardVO;
onCardClick: () => void;
onDeleteClick: (cardVO: PluginCardVO) => void;
onUpgradeClick: (cardVO: PluginCardVO) => void;
}) {
const { t } = useTranslation();
const [dropdownOpen, setDropdownOpen] = useState(false);
return (
<>
<div
className="w-[100%] h-[10rem] bg-white rounded-[10px] shadow-[0px_2px_2px_0_rgba(0,0,0,0.2)] p-[1.2rem] cursor-pointer dark:bg-[#1f1f22]"
onClick={onCardClick}
>
<div className="w-full h-full flex flex-row items-start justify-start gap-[1.2rem]">
{/* <svg
className="w-16 h-16 text-[#2288ee]"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M8 4C8 2.34315 9.34315 1 11 1C12.6569 1 14 2.34315 14 4C14 4.35064 13.9398 4.68722 13.8293 5H18C18.5523 5 19 5.44772 19 6V10.1707C19.3128 10.0602 19.6494 10 20 10C21.6569 10 23 11.3431 23 13C23 14.6569 21.6569 16 20 16C19.6494 16 19.3128 15.9398 19 15.8293V20C19 20.5523 18.5523 21 18 21H4C3.44772 21 3 20.5523 3 20V6C3 5.44772 3.44772 5 4 5H8.17071C8.06015 4.68722 8 4.35064 8 4Z"></path>
</svg> */}
<img
src={httpClient.getPluginIconURL(cardVO.author, cardVO.name)}
alt="plugin icon"
className="w-16 h-16"
/>
<div className="w-full h-full flex flex-col items-start justify-between gap-[0.6rem]">
<div className="flex flex-col items-start justify-start">
<div className="flex flex-col items-start justify-start">
<div className="text-[0.7rem] text-[#666] dark:text-[#999]">
{cardVO.author} / {cardVO.name}
</div>
<div className="flex flex-row items-center justify-start gap-[0.4rem]">
<div className="text-[1.2rem] text-black dark:text-[#f0f0f0]">
{cardVO.label}
</div>
<Badge variant="outline" className="text-[0.7rem]">
v{cardVO.version}
</Badge>
{cardVO.debug && (
<Badge
variant="outline"
className="text-[0.7rem] border-orange-400 text-orange-400"
>
<BugIcon className="w-4 h-4" />
{t('plugins.debugging')}
</Badge>
)}
{!cardVO.debug && (
<>
{cardVO.install_source === 'github' && (
<Badge
variant="outline"
className="text-[0.7rem] border-blue-400 text-blue-400"
onClick={(e) => {
e.stopPropagation();
window.open(
cardVO.install_info.github_url,
'_blank',
);
}}
>
{t('plugins.fromGithub')}
<ExternalLink className="w-4 h-4" />
</Badge>
)}
{cardVO.install_source === 'local' && (
<Badge
variant="outline"
className="text-[0.7rem] border-green-400 text-green-400"
>
{t('plugins.fromLocal')}
</Badge>
)}
{cardVO.install_source === 'marketplace' && (
<Badge
variant="outline"
className="text-[0.7rem] border-purple-400 text-purple-400"
onClick={(e) => {
e.stopPropagation();
window.open(
getCloudServiceClientSync().getPluginMarketplaceURL(
cardVO.author,
cardVO.name,
),
'_blank',
);
}}
>
{t('plugins.fromMarketplace')}
<ExternalLink className="w-4 h-4" />
</Badge>
)}
</>
)}
</div>
</div>
<div className="text-[0.8rem] text-[#666] line-clamp-2 dark:text-[#999]">
{cardVO.description}
</div>
</div>
<div className="w-full flex flex-row items-start justify-start gap-[0.6rem]">
<PluginComponentList
components={cardVO.components}
showComponentName={false}
showTitle={true}
useBadge={false}
t={t}
/>
</div>
</div>
<div className="flex flex-col items-center justify-between h-full">
<div className="flex items-center justify-center"></div>
<div className="flex items-center justify-center">
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<DropdownMenuTrigger asChild>
<Button variant="ghost">
<Ellipsis className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{/**upgrade */}
{cardVO.install_source === 'marketplace' && (
<DropdownMenuItem
className="flex flex-row items-center justify-start gap-[0.4rem] cursor-pointer"
onClick={(e) => {
e.stopPropagation();
onUpgradeClick(cardVO);
setDropdownOpen(false);
}}
>
<ArrowUp className="w-4 h-4" />
<span>{t('plugins.update')}</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
className="flex flex-row items-center justify-start gap-[0.4rem] cursor-pointer"
onClick={(e) => {
e.stopPropagation();
onDeleteClick(cardVO);
setDropdownOpen(false);
}}
>
<Trash className="w-4 h-4" />
<span>{t('plugins.delete')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,132 @@
import { useState, useEffect } from 'react';
import { ApiRespPluginConfig } from '@/app/infra/entities/api';
import { Plugin } from '@/app/infra/entities/plugin';
import { httpClient } from '@/app/infra/http/HttpClient';
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { useTranslation } from 'react-i18next';
import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList';
export default function PluginForm({
pluginAuthor,
pluginName,
onFormSubmit,
onFormCancel,
}: {
pluginAuthor: string;
pluginName: string;
onFormSubmit: (timeout?: number) => void;
onFormCancel: () => void;
}) {
const { t } = useTranslation();
const [pluginInfo, setPluginInfo] = useState<Plugin>();
const [pluginConfig, setPluginConfig] = useState<ApiRespPluginConfig>();
const [isSaving, setIsLoading] = useState(false);
useEffect(() => {
// 获取插件信息
httpClient.getPlugin(pluginAuthor, pluginName).then((res) => {
setPluginInfo(res.plugin);
});
// 获取插件配置
httpClient.getPluginConfig(pluginAuthor, pluginName).then((res) => {
setPluginConfig(res);
});
}, [pluginAuthor, pluginName]);
const handleSubmit = async (values: object) => {
setIsLoading(true);
const isDebugPlugin = pluginInfo?.debug;
httpClient
.updatePluginConfig(pluginAuthor, pluginName, values)
.then(() => {
toast.success(
isDebugPlugin
? t('plugins.saveConfigSuccessDebugPlugin')
: t('plugins.saveConfigSuccessNormal'),
);
onFormSubmit(1000);
})
.catch((error) => {
toast.error(t('plugins.saveConfigError') + error.message);
})
.finally(() => {
setIsLoading(false);
});
};
if (!pluginInfo || !pluginConfig) {
return (
<div className="flex items-center justify-center h-full mb-[2rem]">
{t('plugins.loading')}
</div>
);
}
return (
<div>
<div className="space-y-2">
<div className="text-lg font-medium">
{extractI18nObject(pluginInfo.manifest.manifest.metadata.label)}
</div>
<div className="text-sm text-gray-500 pb-2">
{extractI18nObject(
pluginInfo.manifest.manifest.metadata.description ?? {
en_US: '',
zh_Hans: '',
},
)}
</div>
<div className="mb-4 flex flex-row items-center justify-start gap-[0.4rem]">
<PluginComponentList
components={pluginInfo.components}
showComponentName={true}
showTitle={false}
useBadge={true}
t={t}
/>
</div>
{pluginInfo.manifest.manifest.spec.config.length > 0 && (
<DynamicFormComponent
itemConfigList={pluginInfo.manifest.manifest.spec.config}
initialValues={pluginConfig.config as Record<string, object>}
onSubmit={(values) => {
let config = pluginConfig.config;
config = {
...config,
...values,
};
setPluginConfig({
config: config,
});
}}
/>
)}
{pluginInfo.manifest.manifest.spec.config.length === 0 && (
<div className="text-sm text-gray-500">
{t('plugins.pluginNoConfig')}
</div>
)}
</div>
<div className="sticky bottom-0 left-0 right-0 bg-background border-t p-4 mt-4">
<div className="flex justify-end gap-2">
<Button
type="submit"
onClick={() => handleSubmit(pluginConfig.config)}
disabled={isSaving}
>
{isSaving ? t('plugins.saving') : t('plugins.saveConfig')}
</Button>
<Button type="button" variant="outline" onClick={onFormCancel}>
{t('plugins.cancel')}
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,410 @@
'use client';
import { useState, useEffect, useCallback, useRef, Suspense } from 'react';
import { useSearchParams } from 'next/navigation';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Search, Loader2 } from 'lucide-react';
import PluginMarketCardComponent from './plugin-market-card/PluginMarketCardComponent';
import { PluginMarketCardVO } from './plugin-market-card/PluginMarketCardVO';
import PluginDetailDialog from './plugin-detail-dialog/PluginDetailDialog';
import { getCloudServiceClientSync } from '@/app/infra/http';
import { useTranslation } from 'react-i18next';
import { PluginV4 } from '@/app/infra/entities/plugin';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { toast } from 'sonner';
import { ApiRespMarketplacePlugins } from '@/app/infra/entities/api';
interface SortOption {
value: string;
label: string;
sortBy: string;
sortOrder: string;
}
// 内部组件,用于处理搜索参数
function MarketPageContent({
installPlugin,
}: {
installPlugin: (plugin: PluginV4) => void;
}) {
const { t } = useTranslation();
const searchParams = useSearchParams();
const [searchQuery, setSearchQuery] = useState('');
const [plugins, setPlugins] = useState<PluginMarketCardVO[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [currentPage, setCurrentPage] = useState(1);
const [total, setTotal] = useState(0);
const [sortOption, setSortOption] = useState('install_count_desc');
// Plugin detail dialog state
const [selectedPluginAuthor, setSelectedPluginAuthor] = useState<
string | null
>(null);
const [selectedPluginName, setSelectedPluginName] = useState<string | null>(
null,
);
const [dialogOpen, setDialogOpen] = useState(false);
const pageSize = 16; // 每页16个4行x4列
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// 排序选项
const sortOptions: SortOption[] = [
{
value: 'created_at_desc',
label: t('market.sort.recentlyAdded'),
sortBy: 'created_at',
sortOrder: 'DESC',
},
{
value: 'updated_at_desc',
label: t('market.sort.recentlyUpdated'),
sortBy: 'updated_at',
sortOrder: 'DESC',
},
{
value: 'install_count_desc',
label: t('market.sort.mostDownloads'),
sortBy: 'install_count',
sortOrder: 'DESC',
},
{
value: 'install_count_asc',
label: t('market.sort.leastDownloads'),
sortBy: 'install_count',
sortOrder: 'ASC',
},
];
// 获取当前排序参数
const getCurrentSort = useCallback(() => {
const option = sortOptions.find((opt) => opt.value === sortOption);
return option
? { sortBy: option.sortBy, sortOrder: option.sortOrder }
: { sortBy: 'install_count', sortOrder: 'DESC' };
}, [sortOption]);
// 将API响应转换为VO对象
const transformToVO = useCallback((plugin: PluginV4): PluginMarketCardVO => {
return new PluginMarketCardVO({
pluginId: plugin.author + ' / ' + plugin.name,
author: plugin.author,
pluginName: plugin.name,
label: extractI18nObject(plugin.label),
description:
extractI18nObject(plugin.description) || t('market.noDescription'),
installCount: plugin.install_count,
iconURL: getCloudServiceClientSync().getPluginIconURL(
plugin.author,
plugin.name,
),
githubURL: plugin.repository,
version: plugin.latest_version,
});
}, []);
// 获取插件列表
const fetchPlugins = useCallback(
async (page: number, isSearch: boolean = false, reset: boolean = false) => {
if (page === 1) {
setIsLoading(true);
} else {
setIsLoadingMore(true);
}
try {
let response;
const { sortBy, sortOrder } = getCurrentSort();
if (isSearch && searchQuery.trim()) {
response = await getCloudServiceClientSync().searchMarketplacePlugins(
searchQuery.trim(),
page,
pageSize,
sortBy,
sortOrder,
);
} else {
response = await getCloudServiceClientSync().getMarketplacePlugins(
page,
pageSize,
sortBy,
sortOrder,
);
}
const data: ApiRespMarketplacePlugins = response;
const newPlugins = data.plugins.map(transformToVO);
const total = data.total;
if (reset || page === 1) {
setPlugins(newPlugins);
} else {
setPlugins((prev) => [...prev, ...newPlugins]);
}
setTotal(total);
setHasMore(
data.plugins.length === pageSize &&
plugins.length + newPlugins.length < total,
);
} catch (error) {
console.error('Failed to fetch plugins:', error);
toast.error(t('market.loadFailed'));
} finally {
setIsLoading(false);
setIsLoadingMore(false);
}
},
[searchQuery, pageSize, transformToVO, plugins.length, getCurrentSort],
);
// 初始加载
useEffect(() => {
fetchPlugins(1, false, true);
}, []);
// 搜索功能
const handleSearch = useCallback(
(query: string) => {
setSearchQuery(query);
setCurrentPage(1);
setPlugins([]);
fetchPlugins(1, !!query.trim(), true);
},
[fetchPlugins],
);
// 防抖搜索
const handleSearchInputChange = useCallback(
(value: string) => {
setSearchQuery(value);
// 清除之前的定时器
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
// 设置新的定时器
searchTimeoutRef.current = setTimeout(() => {
handleSearch(value);
}, 300);
},
[handleSearch],
);
// 排序选项变化处理
const handleSortChange = useCallback((value: string) => {
setSortOption(value);
setCurrentPage(1);
setPlugins([]);
// fetchPlugins will be called by useEffect when sortOption changes
}, []);
// 当排序选项变化时重新加载数据
useEffect(() => {
fetchPlugins(1, !!searchQuery.trim(), true);
}, [sortOption]);
// 处理URL参数检查是否需要打开插件详情对话框
useEffect(() => {
const author = searchParams.get('author');
const pluginName = searchParams.get('plugin');
if (author && pluginName) {
setSelectedPluginAuthor(author);
setSelectedPluginName(pluginName);
setDialogOpen(true);
}
}, [searchParams]);
// 插件详情对话框处理函数
const handlePluginClick = useCallback(
(author: string, pluginName: string) => {
setSelectedPluginAuthor(author);
setSelectedPluginName(pluginName);
setDialogOpen(true);
},
[],
);
const handleDialogClose = useCallback(() => {
setDialogOpen(false);
setSelectedPluginAuthor(null);
setSelectedPluginName(null);
}, []);
// 清理定时器
useEffect(() => {
return () => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
};
}, []);
// 加载更多
const loadMore = useCallback(() => {
if (!isLoadingMore && hasMore) {
const nextPage = currentPage + 1;
setCurrentPage(nextPage);
fetchPlugins(nextPage, !!searchQuery.trim());
}
}, [currentPage, isLoadingMore, hasMore, fetchPlugins, searchQuery]);
// 监听滚动事件
useEffect(() => {
const handleScroll = () => {
if (
window.innerHeight + document.documentElement.scrollTop >=
document.documentElement.offsetHeight - 100
) {
loadMore();
}
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, [loadMore]);
// 安装插件
// const handleInstallPlugin = (plugin: PluginV4) => {
// console.log('install plugin', plugin);
// };
return (
<div className="container mx-auto px-4 py-6 space-y-6">
{/* 搜索框 */}
<div className="flex items-center justify-center">
<div className="relative w-full max-w-2xl">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
<Input
placeholder={t('market.searchPlaceholder')}
value={searchQuery}
onChange={(e) => handleSearchInputChange(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') {
// 立即搜索,清除防抖定时器
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
handleSearch(searchQuery);
}
}}
className="pl-10 pr-4"
/>
</div>
</div>
{/* 排序下拉框 */}
<div className="flex items-center justify-center">
<div className="w-full max-w-2xl flex items-center gap-3">
<span className="text-sm text-muted-foreground whitespace-nowrap">
{t('market.sortBy')}:
</span>
<Select value={sortOption} onValueChange={handleSortChange}>
<SelectTrigger className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
{sortOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 搜索结果统计 */}
{total > 0 && (
<div className="text-center text-muted-foreground">
{searchQuery
? t('market.searchResults', { count: total })
: t('market.totalPlugins', { count: total })}
</div>
)}
{/* 插件列表 */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin" />
<span className="ml-2">{t('market.loading')}</span>
</div>
) : plugins.length === 0 ? (
<div className="flex items-center justify-center py-12">
<div className="text-muted-foreground">
{searchQuery ? t('market.noResults') : t('market.noPlugins')}
</div>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-6">
{plugins.map((plugin) => (
<PluginMarketCardComponent
key={plugin.pluginId}
cardVO={plugin}
onPluginClick={handlePluginClick}
/>
))}
</div>
)}
{/* 加载更多指示器 */}
{isLoadingMore && (
<div className="flex items-center justify-center py-6">
<Loader2 className="h-6 w-6 animate-spin" />
<span className="ml-2">{t('market.loadingMore')}</span>
</div>
)}
{/* 没有更多数据提示 */}
{!hasMore && plugins.length > 0 && (
<div className="text-center text-muted-foreground py-6">
{t('market.allLoaded')}
</div>
)}
{/* 插件详情对话框 */}
<PluginDetailDialog
open={dialogOpen}
onOpenChange={handleDialogClose}
author={selectedPluginAuthor}
pluginName={selectedPluginName}
installPlugin={installPlugin}
/>
</div>
);
}
// 主组件,包装在 Suspense 中
export default function MarketPage({
installPlugin,
}: {
installPlugin: (plugin: PluginV4) => void;
}) {
return (
<Suspense
fallback={
<div className="container mx-auto px-4 py-6">
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin" />
<span className="ml-2">...</span>
</div>
</div>
}
>
<MarketPageContent installPlugin={installPlugin} />
</Suspense>
);
}

View File

@@ -0,0 +1,327 @@
'use client';
import { useState, useEffect } from 'react';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Loader2, Download, Users } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { PluginV4 } from '@/app/infra/entities/plugin';
import { getCloudServiceClientSync } from '@/app/infra/http';
import { extractI18nObject } from '@/i18n/I18nProvider';
interface PluginDetailDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
author: string | null;
pluginName: string | null;
installPlugin: (plugin: PluginV4) => void;
}
export default function PluginDetailDialog({
open,
onOpenChange,
author,
pluginName,
installPlugin,
}: PluginDetailDialogProps) {
const { t } = useTranslation();
const [plugin, setPlugin] = useState<PluginV4 | null>(null);
const [readme, setReadme] = useState<string>('');
const [isLoading, setIsLoading] = useState(false);
const [isLoadingReadme, setIsLoadingReadme] = useState(false);
// 获取插件详情和README
useEffect(() => {
if (open && author && pluginName) {
fetchPluginData();
}
}, [open, author, pluginName]);
const fetchPluginData = async () => {
if (!author || !pluginName) return;
setIsLoading(true);
try {
// 获取插件详情
const detailResponse = await getCloudServiceClientSync().getPluginDetail(
author,
pluginName,
);
setPlugin(detailResponse.plugin);
// 获取README
setIsLoadingReadme(true);
try {
const readmeResponse =
await getCloudServiceClientSync().getPluginREADME(author, pluginName);
setReadme(readmeResponse.readme);
} catch (error) {
console.warn('Failed to load README:', error);
setReadme(t('market.noReadme'));
} finally {
setIsLoadingReadme(false);
}
} catch (error) {
console.error('Failed to fetch plugin details:', error);
toast.error(t('market.loadFailed'));
onOpenChange(false);
} finally {
setIsLoading(false);
}
};
if (!open) return null;
const PluginHeader = () => (
<div className="flex items-center gap-4 mb-6">
<img
src={getCloudServiceClientSync().getPluginIconURL(author!, pluginName!)}
alt={plugin!.name}
className="w-16 h-16 rounded-xl border bg-gray-50 object-cover flex-shrink-0"
/>
<div className="flex-1 min-w-0">
<h1 className="text-2xl font-bold text-gray-900 mb-2 dark:text-white">
{extractI18nObject(plugin!.label) || plugin!.name}
</h1>
<div className="flex items-center gap-2 text-sm text-gray-600 mb-3 dark:text-gray-400">
<Users className="w-4 h-4" />
<span>
{plugin!.author} / {plugin!.name}
</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline" className="dark:bg-gray-800 dark:text-white">
v{plugin!.latest_version}
</Badge>
<Badge
variant="outline"
className="flex items-center gap-1 dark:bg-gray-800 dark:text-white"
>
<Download className="w-4 h-4" />
{plugin!.install_count.toLocaleString()} {t('market.downloads')}
</Badge>
{plugin!.repository && (
<button
onClick={(e) => {
e.stopPropagation();
window.open(plugin!.repository, '_blank');
}}
className="flex items-center gap-1 px-2 py-1 text-xs bg-gray-100 hover:bg-gray-200 rounded-md transition-colors dark:bg-gray-800 dark:text-white dark:hover:bg-gray-700 cursor-pointer"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M12.001 2C6.47598 2 2.00098 6.475 2.00098 12C2.00098 16.425 4.86348 20.1625 8.83848 21.4875C9.33848 21.575 9.52598 21.275 9.52598 21.0125C9.52598 20.775 9.51348 19.9875 9.51348 19.15C7.00098 19.6125 6.35098 18.5375 6.15098 17.975C6.03848 17.6875 5.55098 16.8 5.12598 16.5625C4.77598 16.375 4.27598 15.9125 5.11348 15.9C5.90098 15.8875 6.46348 16.625 6.65098 16.925C7.55098 18.4375 8.98848 18.0125 9.56348 17.75C9.65098 17.1 9.91348 16.6625 10.201 16.4125C7.97598 16.1625 5.65098 15.3 5.65098 11.475C5.65098 10.3875 6.03848 9.4875 6.67598 8.7875C6.57598 8.5375 6.22598 7.5125 6.77598 6.1375C6.77598 6.1375 7.61348 5.875 9.52598 7.1625C10.326 6.9375 11.176 6.825 12.026 6.825C12.876 6.825 13.726 6.9375 14.526 7.1625C16.4385 5.8625 17.276 6.1375 17.276 6.1375C17.826 7.5125 17.476 8.5375 17.376 8.7875C18.0135 9.4875 18.401 10.375 18.401 11.475C18.401 15.3125 16.0635 16.1625 13.8385 16.4125C14.201 16.725 14.5135 17.325 14.5135 18.2625C14.5135 19.6 14.501 20.675 14.501 21.0125C14.501 21.275 14.6885 21.5875 15.1885 21.4875C19.259 20.1133 21.9999 16.2963 22.001 12C22.001 6.475 17.526 2 12.001 2Z" />
</svg>
GitHub
</button>
)}
</div>
</div>
</div>
);
const PluginDescription = () => (
<div className="mb-6">
<p className="text-gray-700 leading-relaxed text-base dark:text-gray-400">
{extractI18nObject(plugin!.description) || t('market.noDescription')}
</p>
</div>
);
const PluginOptions = () => (
<div className="space-y-4">
<Button
onClick={() => installPlugin(plugin!)}
className="w-full h-12 text-base font-medium"
>
<Download className="w-5 h-5 mr-2" />
{t('market.install')}
</Button>
</div>
);
const ReadmeContent = () => (
<div className="prose prose-sm max-w-none text-gray-800 dark:text-gray-400">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
// 表格组件
table: ({ ...props }) => (
<div className="my-6 w-full overflow-x-auto rounded-lg">
<table
className="w-full border-collapse bg-white dark:bg-gray-800"
{...props}
/>
</div>
),
thead: ({ ...props }) => (
<thead
className="bg-gray-50 dark:bg-gray-900 dark:text-gray-400"
{...props}
/>
),
tbody: ({ ...props }) => (
<tbody
className="divide-y divide-gray-200 dark:divide-gray-700 dark:text-gray-400"
{...props}
/>
),
th: ({ ...props }) => (
<th
className="px-4 py-3 text-left text-sm font-semibold text-gray-900 border-r border-gray-200 last:border-r-0 dark:border-gray-700 dark:text-gray-400"
{...props}
/>
),
td: ({ ...props }) => (
<td
className="px-4 py-3 text-sm text-gray-700 border-r border-gray-200 last:border-r-0 dark:border-gray-700 dark:text-gray-400"
{...props}
/>
),
tr: ({ ...props }) => (
<tr
className="hover:bg-gray-50 transition-colors dark:hover:bg-gray-800 dark:text-gray-400"
{...props}
/>
),
// 删除线支持
del: ({ ...props }) => (
<del
className="text-gray-500 line-through dark:text-gray-400"
{...props}
/>
),
// Todo 列表支持
input: ({ type, checked, ...props }) => {
if (type === 'checkbox') {
return (
<input
type="checkbox"
checked={checked}
disabled
className="mr-2 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-default dark:border-gray-700"
{...props}
/>
);
}
return <input type={type} {...props} />;
},
ul: ({ ...props }) => (
<ul className="list-disc ml-5 dark:text-gray-400" {...props} />
),
ol: ({ ...props }) => (
<ol className="list-decimal ml-5 dark:text-gray-400" {...props} />
),
li: ({ ...props }) => <li className="mb-1" {...props} />,
h1: ({ ...props }) => (
<h1
className="text-3xl font-bold my-2 dark:text-gray-400"
{...props}
/>
),
h2: ({ ...props }) => (
<h2
className="text-2xl font-semibold mb-2 mt-4 dark:text-gray-400"
{...props}
/>
),
p: ({ ...props }) => (
<p className="leading-relaxed dark:text-gray-400" {...props} />
),
code: ({ className, children, ...props }) => {
const match = /language-(\w+)/.exec(className || '');
const isCodeBlock = match ? true : false;
// 如果是代码块(有语言标识),由 pre 标签处理样式,淡灰色底,黑色字
if (isCodeBlock) {
return (
<code
className="bg-gray-100 text-gray-800 px-1.5 py-0.5 rounded text-sm font-mono dark:bg-gray-800 dark:text-gray-400"
{...props}
>
{children}
</code>
);
}
// 内联代码样式 - 淡灰色底
return (
<code
className="bg-gray-100 text-gray-800 px-1.5 py-0.5 rounded text-sm font-mono inline-block dark:bg-gray-800 dark:text-gray-400"
{...props}
>
{children}
</code>
);
},
pre: ({ ...props }) => (
<pre
className="bg-gray-100 text-gray-800 rounded-lg my-4 shadow-sm max-h-[500px] relative dark:bg-gray-800 dark:text-gray-400"
style={{
// 内边距确保内容不被滚动条覆盖
padding: '16px',
// 保持代码不换行以启用横向滚动
whiteSpace: 'pre',
// 滚动设置
overflowX: 'auto',
overflowY: 'auto',
// 确保滚动条在内部
boxSizing: 'border-box',
}}
{...props}
/>
),
}}
>
{readme}
</ReactMarkdown>
</div>
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="!min-w-[50vw] max-w-none max-h-[90vh] h-[90vh] p-0">
{isLoading ? (
<div className="flex items-center justify-center py-12 h-full">
<Loader2 className="h-8 w-8 animate-spin" />
<span className="ml-2">{t('market.loading')}</span>
</div>
) : plugin ? (
<div className="flex flex-col h-full overflow-hidden">
{/* 插件信息区域 */}
<div className="flex-shrink-0 bg-white border-b m-4 pt-2 dark:bg-black">
<div className="flex gap-6 p-2 px-4">
<div className="flex-1">
<PluginHeader />
<PluginDescription />
</div>
<div className="w-40 pr-4 flex-shrink-0">
<PluginOptions />
</div>
</div>
</div>
{/* README 区域 */}
<div className="flex-1 overflow-hidden px-8">
<div className="h-full bg-white overflow-y-auto pb-2 dark:bg-black">
{isLoadingReadme ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin" />
<span className="ml-3 text-gray-600">
{t('market.loading')}
</span>
</div>
) : (
<ReadmeContent />
)}
</div>
</div>
</div>
) : null}
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,82 @@
import { PluginMarketCardVO } from './PluginMarketCardVO';
export default function PluginMarketCardComponent({
cardVO,
onPluginClick,
}: {
cardVO: PluginMarketCardVO;
onPluginClick?: (author: string, pluginName: string) => void;
}) {
function handleCardClick() {
if (onPluginClick) {
onPluginClick(cardVO.author, cardVO.pluginName);
}
}
return (
<div
className="w-[100%] h-[9rem] bg-white rounded-[10px] shadow-[0px_0px_4px_0_rgba(0,0,0,0.2)] p-[1rem] cursor-pointer hover:shadow-[0px_2px_8px_0_rgba(0,0,0,0.15)] transition-shadow duration-200 dark:bg-[#1f1f22]"
onClick={handleCardClick}
>
<div className="w-full h-full flex flex-col justify-between">
{/* 上部分:插件信息 */}
<div className="flex flex-row items-start justify-start gap-[1.2rem]">
<img src={cardVO.iconURL} alt="plugin icon" className="w-16 h-16" />
<div className="flex-1 flex flex-col items-start justify-start gap-[0.6rem]">
<div className="flex flex-col items-start justify-start">
<div className="text-[0.7rem] text-[#666] dark:text-[#999]">
{cardVO.pluginId}
</div>
<div className="flex flex-row items-center justify-start gap-[0.4rem]">
<div className="text-[1.2rem] text-black dark:text-[#f0f0f0]">
{cardVO.label}
</div>
</div>
</div>
<div className="text-[0.8rem] text-[#666] dark:text-[#999] line-clamp-2">
{cardVO.description}
</div>
</div>
<div className="flex h-full flex-row items-start justify-center gap-[0.4rem]">
{cardVO.githubURL && (
<svg
className="w-[1.4rem] h-[1.4rem] text-black cursor-pointer hover:text-gray-600 dark:text-[#f0f0f0]"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
onClick={(e) => {
e.stopPropagation();
window.open(cardVO.githubURL, '_blank');
}}
>
<path d="M12.001 2C6.47598 2 2.00098 6.475 2.00098 12C2.00098 16.425 4.86348 20.1625 8.83848 21.4875C9.33848 21.575 9.52598 21.275 9.52598 21.0125C9.52598 20.775 9.51348 19.9875 9.51348 19.15C7.00098 19.6125 6.35098 18.5375 6.15098 17.975C6.03848 17.6875 5.55098 16.8 5.12598 16.5625C4.77598 16.375 4.27598 15.9125 5.11348 15.9C5.90098 15.8875 6.46348 16.625 6.65098 16.925C7.55098 18.4375 8.98848 18.0125 9.56348 17.75C9.65098 17.1 9.91348 16.6625 10.201 16.4125C7.97598 16.1625 5.65098 15.3 5.65098 11.475C5.65098 10.3875 6.03848 9.4875 6.67598 8.7875C6.57598 8.5375 6.22598 7.5125 6.77598 6.1375C6.77598 6.1375 7.61348 5.875 9.52598 7.1625C10.326 6.9375 11.176 6.825 12.026 6.825C12.876 6.825 13.726 6.9375 14.526 7.1625C16.4385 5.8625 17.276 6.1375 17.276 6.1375C17.826 7.5125 17.476 8.5375 17.376 8.7875C18.0135 9.4875 18.401 10.375 18.401 11.475C18.401 15.3125 16.0635 16.1625 13.8385 16.4125C14.201 16.725 14.5135 17.325 14.5135 18.2625C14.5135 19.6 14.501 20.675 14.501 21.0125C14.501 21.275 14.6885 21.5875 15.1885 21.4875C19.259 20.1133 21.9999 16.2963 22.001 12C22.001 6.475 17.526 2 12.001 2Z"></path>
</svg>
)}
</div>
</div>
{/* 下部分:下载量 */}
<div className="w-full flex flex-row items-center justify-start gap-[0.4rem] px-[0.4rem]">
<svg
className="w-[1.2rem] h-[1.2rem] text-[#2563eb]"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7,10 12,15 17,10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
<div className="text-sm text-[#2563eb] font-medium">
{cardVO.installCount.toLocaleString()}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,35 @@
export interface IPluginMarketCardVO {
pluginId: string;
author: string;
pluginName: string;
label: string;
description: string;
installCount: number;
iconURL: string;
githubURL: string;
version: string;
}
export class PluginMarketCardVO implements IPluginMarketCardVO {
pluginId: string;
description: string;
label: string;
author: string;
pluginName: string;
iconURL: string;
githubURL: string;
installCount: number;
version: string;
constructor(prop: IPluginMarketCardVO) {
this.description = prop.description;
this.label = prop.label;
this.author = prop.author;
this.pluginName = prop.pluginName;
this.iconURL = prop.iconURL;
this.githubURL = prop.githubURL;
this.installCount = prop.installCount;
this.pluginId = prop.pluginId;
this.version = prop.version;
}
}

View File

@@ -0,0 +1,215 @@
// 'use client';
// import * as React from 'react';
// import { useState, useEffect } from 'react';
// import { PluginCardVO } from '@/app/home/plugins/plugin-installed/PluginCardVO';
// import { httpClient } from '@/app/infra/http/HttpClient';
// import { PluginReorderElement } from '@/app/infra/entities/api';
// import { toast } from 'sonner';
// import {
// Dialog,
// DialogContent,
// DialogHeader,
// DialogTitle,
// DialogFooter,
// } from '@/components/ui/dialog';
// import { Button } from '@/components/ui/button';
// import {
// DndContext,
// closestCenter,
// KeyboardSensor,
// PointerSensor,
// useSensor,
// useSensors,
// DragEndEvent,
// } from '@dnd-kit/core';
// import {
// arrayMove,
// SortableContext,
// sortableKeyboardCoordinates,
// useSortable,
// verticalListSortingStrategy,
// } from '@dnd-kit/sortable';
// import { CSS } from '@dnd-kit/utilities';
// import { useTranslation } from 'react-i18next';
// import { extractI18nObject } from '@/i18n/I18nProvider';
// interface PluginSortDialogProps {
// open: boolean;
// onOpenChange: (open: boolean) => void;
// onSortComplete: () => void;
// }
// function SortablePluginItem({ plugin }: { plugin: PluginCardVO }) {
// const { attributes, listeners, setNodeRef, transform, transition } =
// useSortable({
// id: `${plugin.author}-${plugin.name}`,
// });
// const style = {
// transform: CSS.Transform.toString(transform),
// transition,
// };
// return (
// <div
// ref={setNodeRef}
// style={style}
// {...attributes}
// {...listeners}
// className="bg-white dark:bg-gray-800 p-4 rounded-md shadow-sm border mb-2 cursor-move"
// >
// <div className="flex flex-col">
// <div className="text-sm text-gray-600 dark:text-gray-400">
// {plugin.author}
// </div>
// <div className="text-lg font-medium">{plugin.name}</div>
// <div className="text-sm line-clamp-2 text-gray-500 dark:text-gray-400 mt-1">
// {plugin.description}
// </div>
// </div>
// </div>
// );
// }
// export default function PluginSortDialog({
// open,
// onOpenChange,
// onSortComplete,
// }: PluginSortDialogProps) {
// const { t } = useTranslation();
// const [sortedPlugins, setSortedPlugins] = useState<PluginCardVO[]>([]);
// const [isLoading, setIsLoading] = useState(false);
// function getPluginList() {
// httpClient.getPlugins().then((value) => {
// setSortedPlugins(
// value.plugins.map((plugin) => {
// return new PluginCardVO({
// author: plugin.manifest.manifest.metadata.author ?? '',
// description: extractI18nObject(
// plugin.manifest.manifest.metadata.description ?? {
// en_US: '',
// zh_Hans: '',
// },
// ),
// enabled: plugin.enabled,
// name: plugin.manifest.manifest.metadata.name,
// version: plugin.manifest.manifest.metadata.version ?? '',
// status: plugin.status,
// components: plugin.components,
// install_source: plugin.install_source,
// install_info: plugin.install_info,
// priority: plugin.priority,
// debug: plugin.debug,
// });
// }),
// );
// });
// }
// useEffect(() => {
// if (open) {
// getPluginList();
// }
// }, [open]);
// const sensors = useSensors(
// useSensor(PointerSensor),
// useSensor(KeyboardSensor, {
// coordinateGetter: sortableKeyboardCoordinates,
// }),
// );
// function handleDragEnd(event: DragEndEvent) {
// const { active, over } = event;
// console.log('Drag end event:', { active, over });
// if (over && active.id !== over.id) {
// setSortedPlugins((items) => {
// const oldIndex = items.findIndex(
// (item) => `${item.author}-${item.name}` === active.id,
// );
// const newIndex = items.findIndex(
// (item) => `${item.author}-${item.name}` === over.id,
// );
// const newItems = arrayMove(items, oldIndex, newIndex);
// return newItems;
// });
// }
// }
// function handleSave() {
// setIsLoading(true);
// const reorderElements: PluginReorderElement[] = sortedPlugins.map(
// (plugin, index) => ({
// author: plugin.author,
// name: plugin.name,
// priority: index,
// }),
// );
// httpClient
// .reorderPlugins(reorderElements)
// .then(() => {
// toast.success(t('plugins.pluginSortSuccess'));
// onSortComplete();
// onOpenChange(false);
// })
// .catch((err) => {
// toast.error(t('plugins.pluginSortError') + err.message);
// })
// .finally(() => {
// setIsLoading(false);
// });
// }
// return (
// <Dialog open={open} onOpenChange={onOpenChange}>
// <DialogContent className="w-[700px] max-h-[80vh] p-0 flex flex-col">
// <DialogHeader className="px-6 pt-6 pb-0">
// <DialogTitle>{t('plugins.pluginSort')}</DialogTitle>
// </DialogHeader>
// <div className="flex-1 overflow-y-auto px-6 py-0">
// <p className="text-sm text-gray-500 mb-4">
// {t('plugins.pluginSortDescription')}
// </p>
// <DndContext
// sensors={sensors}
// collisionDetection={closestCenter}
// onDragEnd={handleDragEnd}
// >
// <SortableContext
// items={sortedPlugins.map(
// (plugin) => `${plugin.author}-${plugin.name}`,
// )}
// strategy={verticalListSortingStrategy}
// >
// {sortedPlugins.map((plugin) => (
// <SortablePluginItem
// key={`${plugin.author}-${plugin.name}`}
// plugin={plugin}
// />
// ))}
// </SortableContext>
// </DndContext>
// </div>
// <DialogFooter className="px-6 py-4">
// <Button
// variant="outline"
// onClick={() => onOpenChange(false)}
// disabled={isLoading}
// >
// {t('common.cancel')}
// </Button>
// <Button onClick={handleSave} disabled={isLoading}>
// {isLoading ? t('common.saving') : t('common.save')}
// </Button>
// </DialogFooter>
// </DialogContent>
// </Dialog>
// );
// }